diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 656d5193e..948e2e64e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,14 +13,12 @@ jobs: strategy: matrix: xcode: - - "13.2.1" # Swift 5.5.2 - - "13.4.1" # Swift 5.6.1 - - "14.0" # Swift 5.7 + - "14.3.1" - name: macOS 12 (Xcode ${{ matrix.xcode }}) - runs-on: macos-12 + name: macOS 13 (Xcode ${{ matrix.xcode }}) + runs-on: macos-13 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Run tests @@ -30,8 +28,6 @@ jobs: strategy: matrix: swift: - - "5.5" - - "5.6" - "5.7" name: Ubuntu (Swift ${{ matrix.swift }}) @@ -40,16 +36,14 @@ jobs: - uses: swift-actions/setup-swift@v1 with: swift-version: ${{ matrix.swift }} - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - run: swift test windows: strategy: matrix: swift: - - "5.5" - - "5.6" - #- "5.7" + - "5.8" name: Windows (Swift ${{ matrix.swift }}) runs-on: windows-2019 @@ -65,6 +59,6 @@ jobs: git config --global core.autocrlf false git config --global core.eol lf - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - run: swift build - run: swift test diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 000000000..267109cff --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,29 @@ +name: Format + +on: + push: + branches: + - main + +concurrency: + group: format-${{ github.ref }} + cancel-in-progress: true + +jobs: + swift_format: + name: swift-format + runs-on: macos-12 + steps: + - uses: actions/checkout@v3 + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_14.3.1.app + - name: Install + run: brew install swift-format + - name: Format + run: make format + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Run swift-format + branch: 'main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Documentation/Available-Snapshot-Strategies.md b/Documentation/Available-Snapshot-Strategies.md deleted file mode 100644 index b56c9db59..000000000 --- a/Documentation/Available-Snapshot-Strategies.md +++ /dev/null @@ -1,913 +0,0 @@ -# Available Snapshot Strategies - -SnapshotTesting comes with a wide variety of snapshot strategies for a variety of platforms. To extend these strategies or define your own, see [Defining Custom Snapshot Strategies](Defining-Custom-Snapshot-Strategies.md). - -If you'd like to submit your own custom strategy, see [Contributing](../CONTRIBUTING.md). - -## List of Available Snapshot Strategies - - - [`Any`](#any) - - [`.description`](#description) - - [`.dump`](#dump) - - [`CALayer`](#calayer) - - [`.image`](#image) - - [`CaseIterable`](#caseiterable) - - [`CGPath`](#cgpath) - - [`.image`](#image-1) - - [`.elementsDescription`](#elementsdescription) - - [`Data`](#data) - - [`.data`](#data=1) - - [`Encodable`](#encodable) - - [`.json`](#json) - - [`.plist`](#plist) - - [`NSBezierPath`](#nsbezierpath) - - [`.image`](#image-2) - - [`.elementsDescription`](#elementsdescription-1) - - [`NSImage`](#nsimage) - - [`.image`](#image-3) - - [`NSView`](#nsview) - - [`.image`](#image-4) - - [`.recursiveDescription`](#recursivedescription) - - [`NSViewController`](#nsviewcontroller) - - [`.image`](#image-5) - - [`.recursiveDescription`](#recursivedescription-1) - - [`SCNScene`](#scnscene) - - [`.image`](#image-6) - - [`SKScene`](#skscene) - - [`.image`](#image-7) - - [`String`](#string) - - [`.lines`](#lines) - - [`UIBezierPath`](#uibezierpath) - - [`.image`](#image-8) - - [`.elementsDescription`](#elementsdescription-2) - - [`UIImage`](#uiimage) - - [`.image`](#image-9) - - [`UIView`](#uiview) - - [`.image`](#image-10) - - [`.recursiveDescription`](#recursivedescription-2) - - [`UIViewController`](#uiviewcontroller) - - [`.hierarchy`](#hierarchy) - - [`.image`](#image-11) - - [`.recursiveDescription`](#recursivedescription-3) - - [`URLRequest`](#urlrequest) - - [`.curl`](#curl) - - [`.raw`](#raw) - -## Any - -**Platforms:** All - -### `.description` - -A snapshot strategy that captures a value's textual description from `String`'s `init(description:)` initializer. - -**Format:** `String` - -#### Example: - -``` swift -assertSnapshot(of: user, as: .description) -``` - -Records: - -``` -User(bio: "Blobbed around the world.", id: 1, name: "Blobby") -``` - -**See also**: [`.dump`](#dump). - -### `.dump` - -A snapshot strategy for comparing _any_ structure based on a sanitized text dump. - -The reference format looks a lot like the output of Swift's built-in [`dump`](https://developer.apple.com/documentation/swift/1539127-dump) function, though it does its best to make output deterministic by stripping out pointer memory addresses and sorting non-deterministic data, like dictionaries and sets. - -You can hook into how an instance of a type is rendered in this strategy by conforming to the `AnySnapshotStringConvertible` protocol and defining the `snapshotDescription` property. - -**Format:** `String` - -#### Example: - -``` swift -assertSnapshot(of: user, as: .dump) -``` - -Records: - -``` -▿ User - - bio: "Blobbed around the world." - - id: 1 - - name: "Blobby" -``` - -**See also**: [`.description`](#description). - -## CALayer - -**Platforms:** iOS, macOS, tvOS - -### `.image` - -A snapshot strategy for comparing layers based on pixel equality. - -**Format:** `NSImage`, `UIImage` - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - -#### Example: - -``` swift -// Match reference perfectly. -assertSnapshot(of: layer, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: layer, as: .image(precision: 0.99)) -``` - -## CaseIterable - -**Platforms:** All - -### `.func(into:)` - -A snapshot strategy for functions on `CaseIterable` types. It feeds every possible input into the function and puts the inputs and outputs into a CSV table. - -**Format**: Comma-separated values (CSV) - -#### Parameters: - -A snapshotting strategy on the output of the function you are snapshotting. - -#### Example: - -```swift -enum Direction: String, CaseIterable { - case up, down, left, right - var rotatedLeft: Direction { - switch self { - case .up: return .left - case .down: return .right - case .left: return .down - case .right: return .up - } - } -} - -assertSnapshot( - of: { $0.rotatedLeft }, - as: Snapshotting.func(into: .description) -) -``` - -Records: - -```csv -"up","left" -"down","right" -"left","down" -"right","up" -``` - -## CGPath - -**Platforms:** iOS, macOS, tvOS - -### `.image` - -A snapshot strategy for comparing paths based on pixel equality. - -**Format:** `NSImage`, `UIImage` - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - -#### Example: - -``` swift -// Match reference perfectly. -assertSnapshot(of: path, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: path, as: .image(precision: 0.99)) -``` - -### `.elementsDescription` - -A snapshot strategy for comparing paths based on a description of their elements. - -**Format:** `String` - -#### Parameters: - - - `numberFormatter: NumberFormatter` - - The number formatter used for formatting points (default: 1-3 fraction digits). - -#### Example: - -``` swift -// Match reference perfectly. -assertSnapshot(of: path, as: .elementsDescription) - -// Match reference as formatted by formatter. -assertSnapshot(of: path, as: .elementsDescription(numberFormatter: NumberFormatter())) -``` - -## Data - -**Platforms:** All - -### `.data` - -A snapshot strategy for comparing bare binary data. - -#### Example: - -``` swift -assertSnapshot(of: data, as: .data) -``` - -## Encodable - -**Platforms:** All - -### `.json` - -A snapshot strategy for comparing encodable structures based on their JSON representation. - -**Format:** `String` - -#### Parameters: - - - `encoder: JSONEncoder` (optional) - -#### Example: - -``` swift -assertSnapshot(of: user, as: .json) -``` - -Records: - -``` json -{ - "bio" : "Blobbed around the world.", - "id" : 1, - "name" : "Blobby" -} -``` - -### `.plist` - -A snapshot strategy for comparing encodable structures based on their property list representation. - -**Format:** `String` - -#### Parameters: - - - `encoder: PropertyListEncoder` (optional) - -#### Example: - -``` swift -assertSnapshot(of: user, as: .plist) -``` - -Records: - -``` xml - - - - - bio - Blobbed around the world. - id - 1 - name - Blobby - - -``` - -## NSBezierPath - -**Platforms:** macOS - -### `.image` - -A snapshot strategy for comparing paths based on pixel equality. - -**Format:** `NSImage` - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - -#### Example: - -``` swift -// Match reference perfectly. -assertSnapshot(of: path, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: path, as: .image(precision: 0.99)) -``` - -### `.elementsDescription` - -A snapshot strategy for comparing paths based on a description of their elements. - -**Format:** `String` - -#### Parameters: - - - `numberFormatter: NumberFormatter` - - The number formatter used for formatting points (default: 1-3 fraction digits). - -#### Example: - -``` swift -// Match reference perfectly. -assertSnapshot(of: path, as: .elementsDescription) - -// Match reference as formatted by formatter. -assertSnapshot(of: path, as: .elementsDescription(numberFormatter: NumberFormatter())) -``` - -## NSImage - -**Platforms:** macOS - -### `.image` - -A snapshot strategy for comparing images based on pixel equality. - -**Format:** `NSImage` - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - -#### Example: - -``` swift -// Match reference as-is. -assertSnapshot(of: image, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: image, as: .image(precision: 0.99)) -``` - -## NSView - -**Platforms:** macOS - -### `.image` - -A snapshot strategy for comparing layers based on pixel equality. - -> Note: Snapshots must be compared on the same OS as the device that originally took the reference to avoid discrepancies between images. - -**Format:** `NSImage` - -**Note:** Includes `SCNView`, `SKView`, `WKWebView`. - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - - - `size: CGSize = nil` - - A view size override. - -#### Example: - -``` swift -// Match reference as-is. -assertSnapshot(of: view, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: view, as: .image(precision: 0.99)) - -// Render at a certain size. -assertSnapshot( - of: view, - as: .image(size: .init(width: 44, height: 44)) -) -``` - -### `.recursiveDescription` - -A snapshot strategy for comparing views based on a recursive description of their properties and hierarchies. - -**Format:** `String` - -#### Example - -``` swift -assertSnapshot(of: view, as: .recursiveDescription) -``` - -Records: - -``` -[ AF LU ] h=--- v=--- NSButton "Push Me" f=(0,0,77,32) b=(-) - [ A LU ] h=--- v=--- NSButtonBezelView f=(0,0,77,32) b=(-) - [ AF LU ] h=--- v=--- NSButtonTextField "Push Me" f=(10,6,57,16) b=(-) -A=autoresizesSubviews, C=canDrawConcurrently, D=needsDisplay, F=flipped, G=gstate, H=hidden (h=by ancestor), L=needsLayout (l=child needsLayout), U=needsUpdateConstraints (u=child needsUpdateConstraints), O=opaque, P=preservesContentDuringLiveResize, S=scaled/rotated, W=wantsLayer (w=ancestor wantsLayer), V=needsVibrancy (v=allowsVibrancy), #=has surface -``` - -## NSViewController - -> Note: Snapshots must be compared on the same OS as the device that originally took the reference to avoid discrepancies between images. - -### `.image` - -A snapshot strategy for comparing layers based on pixel equality. - -**Format:** `NSImage` - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - - - `size: CGSize = nil` - - A view size override. - -#### Example: - -``` swift -// Match reference as-is. -assertSnapshot(of: vc, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: vc, as: .image(precision: 0.99)) - -// Render at a certain size. -assertSnapshot( - of: vc, - as: .image(size: .init(width: 640, height: 480)) -) -``` - -**See also**: [`NSView`](#nsview). - -### `.recursiveDescription` - -A snapshot strategy for comparing view controller views based on a recursive description of their properties and hierarchies. - -**Format:** `String` - -#### Example - -``` swift -assertSnapshot(of: vc, as: .recursiveDescription) -``` - -Records: - -``` -[ AF LU ] h=--- v=--- NSButton "Push Me" f=(0,0,77,32) b=(-) - [ A LU ] h=--- v=--- NSButtonBezelView f=(0,0,77,32) b=(-) - [ AF LU ] h=--- v=--- NSButtonTextField "Push Me" f=(10,6,57,16) b=(-) -A=autoresizesSubviews, C=canDrawConcurrently, D=needsDisplay, F=flipped, G=gstate, H=hidden (h=by ancestor), L=needsLayout (l=child needsLayout), U=needsUpdateConstraints (u=child needsUpdateConstraints), O=opaque, P=preservesContentDuringLiveResize, S=scaled/rotated, W=wantsLayer (w=ancestor wantsLayer), V=needsVibrancy (v=allowsVibrancy), #=has surface -``` - -## SCNScene - -### `.image` - -A snapshot strategy for comparing SceneKit scenes based on pixel equality. - -**Platforms:** iOS, macOS, tvOS - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - - - `size: CGSize` - - The size of the scene. - -#### Example: - -``` swift -assertSnapshot( - of: scene, - as: .image(size: .init(width: 640, height: 480)) -) -``` - -**See also**: [`NSView`](#nsview), [`UIView`](#uiview). - -## SKScene - -### `.image` - -A snapshot strategy for comparing SpriteKit scenes based on pixel equality. - -**Platforms:** iOS, macOS, tvOS - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - - - `size: CGSize` - - The size of the scene. - -#### Example: - -``` swift -assertSnapshot( - of: scene, - as: .image(size: .init(width: 640, height: 480)) -) -``` - -**See also**: [`NSView`](#nsview), [`UIView`](#uiview). - -## String - -**Platforms:** All - -### `.lines` - -A snapshot strategy for comparing strings based on equality. - -**Format:** `String` - -#### Example: - -``` swift -assertSnapshot(of: htmlString, as: .lines) -``` - -## UIBezierPath - -**Platforms:** iOS, tvOS - -### `.image` - -A snapshot strategy for comparing paths based on pixel equality. - -**Format:** `UIImage` - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - -#### Example: - -``` swift -// Match reference perfectly. -assertSnapshot(of: path, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: path, as: .image(precision: 0.99)) -``` - -### `.elementsDescription` - -A snapshot strategy for comparing paths based on a description of their elements. - -**Format:** `String` - -#### Parameters: - - - `numberFormatter: NumberFormatter` - - The number formatter used for formatting points (default: 1-3 fraction digits). - -#### Example: - -``` swift -// Match reference perfectly. -assertSnapshot(of: path, as: .elementsDescription) - -// Match reference as formatted by formatter. -assertSnapshot(of: path, as: .elementsDescription(numberFormatter: NumberFormatter())) -``` - -## UIImage - -**Platforms:** iOS, tvOS - -### `.image` - -A snapshot strategy for comparing images based on pixel equality. - -**Format:** `UIImage` - -#### Parameters: - - - `precision: Float = 1` - - The percentage of pixels that must match. - -#### Example: - -``` swift -// Match reference as-is. -assertSnapshot(of: image, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: image, as: .image(precision: 0.99)) -``` - -## UIView - -**Platforms:** iOS, tvOS - -### `.image` - -A snapshot strategy for comparing layers based on pixel equality. - -> Note: 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. - -**Format:** `UIImage` - -**Note:** Includes `SCNView`, `SKView`, `WKWebView`. - -#### Parameters: - - - `drawHierarchyInKeyWindow: Bool = false` - - Utilize the simulator's key window in order to render `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your tests and will _not_ work for framework test targets. - - - `precision: Float = 1` - - The percentage of pixels that must match. - - - `size: CGSize = nil` - - A view size override. - - - `traits: UITraitCollection = .init()` - - A trait collection override. - -#### Example: - -``` swift -// Match reference as-is. -assertSnapshot(of: view, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: view, as: .image(precision: 0.99)) - -// Render at a certain size. -assertSnapshot( - of: view, - as: .image(size: .init(width: 44, height: 44)) -) - -// Render with a horizontally-compact size class. -assertSnapshot( - of: view, - as: .image(traits: .init(horizontalSizeClass: .regular)) -) -``` - -### `.recursiveDescription` - -A snapshot strategy for comparing views based on a recursive description of their properties and hierarchies. - -**Format:** `String` - -#### Parameters: - - - `size: CGSize = nil` - - A view size override. - - - `traits: UITraitCollection = .init()` - - A trait collection override. - -#### Example - -``` swift -// Layout on the current device. -assertSnapshot(of: view, as: .recursiveDescription) - -// Layout with a certain size. -assertSnapshot(of: view, as: .recursiveDescription(size: .init(width: 22, height: 22))) - -// Layout with a certain trait collection. -assertSnapshot(of: view, as: .recursiveDescription(traits: .init(horizontalSizeClass: .regular))) -``` - -Records: - -``` -> - | > -``` - -## UIViewController - -**Platforms:** iOS, tvOS - -### `.hierarchy` - -A snapshot strategy for comparing view controllers based on their embedded controller hierarchy. - -**Format:** `String` - -#### Example - -``` swift -assertSnapshot(of: vc, as: .hierarchy) -``` - -Records: - -``` -, state: appeared, view: - | , state: appeared, view: - | | , state: appeared, view: <_UIPageViewControllerContentView> - | | | , state: appeared, view: - | , state: disappeared, view: not in the window - | | , state: disappeared, view: (view not loaded) - | , state: disappeared, view: not in the window - | | , state: disappeared, view: (view not loaded) - | , state: disappeared, view: not in the window - | | , state: disappeared, view: (view not loaded) - | , state: disappeared, view: not in the window - | | , state: disappeared, view: (view not loaded) -``` - -### `.image` - -A snapshot strategy for comparing layers based on pixel equality. - -> Note: 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. - -**Format:** `UIImage` - -#### Parameters: - - - `drawHierarchyInKeyWindow: Bool = false` - - Utilize the simulator's key window in order to render `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your tests and will _not_ work for framework test targets. - - _Incompatible with the `on` parameter._ - - - `on: ViewImageConfig` - - A set of device configuration settings. - - _Incompatible with the `drawHierarchyInKeyWindow` parameter._ - - - `precision: Float = 1` - - The percentage of pixels that must match. - - - `size: CGSize = nil` - - A view size override. - - - `traits: UITraitCollection = .init()` - - A trait collection override. - -#### Example: - -``` swift -// Match reference as-is. -assertSnapshot(of: vc, as: .image) - -// Allow for a 1% pixel difference. -assertSnapshot(of: vc, as: .image(precision: 0.99)) - -// Render as if on a certain device. -assertSnapshot(of: vc, as: .image(on: .iPhoneX(.portrait))) - -// Render at a certain size. -assertSnapshot( - of: vc, - as: .image(size: .init(width: 375, height: 667)) -) - -// Render with a horizontally-compact size class. -assertSnapshot( - of: vc, - as: .image(traits: .init(horizontalSizeClass: .compact)) -) - -// Match reference as-is. -assertSnapshot(of: vc, as: .image) -``` - -**See also**: [`UIView`](#uiview). - -### `.recursiveDescription` - -A snapshot strategy for comparing view controller views based on a recursive description of their properties and hierarchies. - -**Format:** `String` - -#### Parameters: - - - `on: ViewImageConfig` - - A set of device configuration settings. - - - `size: CGSize = nil` - - A view size override. - - - `traits: UITraitCollection = .init()` - - A trait collection override. - -#### Example - -``` swift -// Layout on the current device. -assertSnapshot(of: vc, as: .recursiveDescription) - -// Layout as if on a certain device. -assertSnapshot(of: vc, as: .recursiveDescription(on: .iPhoneSe(.portrait))) -``` - -Records: - -``` -> - | > -``` - -## URLRequest - -**Platforms:** All - -### `.curl` - -A snapshot strategy for comparing requests based on a cURL representation. - -**Format:** `String` - -#### Example: - -``` swift -assertSnapshot(of: request, as: .curl) -``` - -Records: - -``` -curl \ - --request POST \ - --header "Accept: text/html" \ - --data 'pricing[billing]=monthly&pricing[lane]=individual' \ - "https://www.pointfree.co/subscribe" -``` - -### `.raw` - -A snapshot strategy for comparing requests based on raw equality. - -**Format:** `String` - -#### Example: - -``` swift -assertSnapshot(of: request, as: .raw) -``` - -Records: - -``` -POST http://localhost:8080/account -Cookie: pf_session={"userId":"1"} - -email=blob%40pointfree.co&name=Blob -``` diff --git a/Documentation/Defining-Custom-Snapshot-Strategies.md b/Documentation/Defining-Custom-Snapshot-Strategies.md deleted file mode 100644 index 7aa6969db..000000000 --- a/Documentation/Defining-Custom-Snapshot-Strategies.md +++ /dev/null @@ -1,82 +0,0 @@ -# Defining Custom Snapshot Strategies - -While SnapshotTesting comes with [a wide variety of snapshot strategies](Available-Snapshot-Strategies.md), it can also be extended with custom, user-defined strategies using the [`Snapshotting`](#snapshottingvalue-format) and [`Diffing`](#diffingvalue) types. - -## `Snapshotting` - -The [`Snapshotting`](../Sources/SnapshotTesting/Snapshotting.swift) type represents the ability to transform a snapshottable value (like a view or data structure) into a diffable format (like an image or text). - -### Transforming Existing Strategies - -Existing strategies can be transformed to work with new types using the `pullback` method. - -For example, given the following `image` strategy on `UIView`: - -``` swift -Snapshotting.image -``` - -We can define an `image` strategy on `UIViewController` using the `pullback` method: - -``` swift -extension Snapshotting where Value == UIViewController, Format == UIImage { - public static let image: Snapshotting = - Snapshotting.image.pullback { vc in vc.view } -} -``` - -Pullback takes a transform function from the new strategy's value to the existing strategy's value, in this case `(UIViewController) -> UIView`. - -### Creating Brand New Strategies - -Most strategies can be built from existing ones, but if you've defined your own [`Diffing`](#diffingvalue) strategy, you may need to create a base `Snapshotting` value alongside it. - -### Asynchronous Strategies - -Some types need to be snapshot in an asynchronous fashion. `Snapshotting` offers two APIs for building asynchronous strategies by utilizing a built-in [`Async`](../Sources/SnapshotTesting/Async.swift) type. - -#### `asyncPullback` - -Alongside [`pullback`](#transforming-sxisting-strategies), `Snapshotting` defines `asyncPullback`, which takes a transform function `(NewStrategyValue) -> Async`. - -For example, WebKit's `WKWebView` offers a callback-based API for taking image snapshots, where the image is passed asynchronously to the callback block. While `pullback` would require the `UIImage` to be returned from the transform function, `asyncPullback` and `Async` allow us to pass the `image` a value that can pass its callback along to the scope in which the image has been created. - -``` swift -extension Snapshotting where Value == WKWebView, Format == UIImage { - public static let image: Snapshotting = Snapshotting.image - .asyncPullback { webView in - - Async { callback in - webView.takeSnapshot(with: nil) { image, error in - callback(image!) - } - } - } -} -``` - -#### Async Initializer - -`Snapshotting` defines an alternate initializer to describe snapshotting values in an asynchronous fashion. - -For example, were we to define a strategy for `WKWebView` _without_ [`asyncPullback`](#asyncpullback): - -``` swift -extension Snapshotting where Value == WKWebView, Format == UIImage { - public static let image = Snapshotting( - pathExtension: "png", - diffing: .image, - asyncSnapshot: { webView in - Async { callback in - webView.takeSnapshot(with: nil) { image, error in - callback(image!) - } - } - } - ) -} -``` - -## `Diffing` - -The [`Diffing`](../Sources/SnapshotTesting/Diffing.swift) type represents the ability to compare `Value`s and convert them to and from `Data`. diff --git a/Makefile b/Makefile index 2c6ba9426..c28ea7f2c 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ test-ios: set -o pipefail && \ xcodebuild test \ -scheme SnapshotTesting \ - -destination platform="iOS Simulator,name=iPhone 11 Pro Max,OS=13.3" \ + -destination platform="iOS Simulator,name=iPhone 11 Pro Max,OS=13.3" test-swift: swift test @@ -25,6 +25,13 @@ test-tvos: set -o pipefail && \ xcodebuild test \ -scheme SnapshotTesting \ - -destination platform="tvOS Simulator,name=Apple TV 4K,OS=13.3" \ + -destination platform="tvOS Simulator,name=Apple TV 4K,OS=13.3" + +format: + swift format \ + --ignore-unparsable-files \ + --in-place \ + --recursive \ + ./Package.swift ./Sources ./Tests test-all: test-linux test-macos test-ios diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 000000000..65893db13 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "swift-syntax", + "repositoryURL": "https://github.com/apple/swift-syntax.git", + "state": { + "branch": null, + "revision": "7bb5231dad28c6dceabf7a439867f39d2c105e4f", + "version": "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-09-05-a" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index 12bd0a918..0976b6f24 100644 --- a/Package.swift +++ b/Package.swift @@ -1,28 +1,59 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.7 + import PackageDescription let package = Package( name: "swift-snapshot-testing", platforms: [ - .iOS(.v11), - .macOS(.v10_10), - .tvOS(.v10), + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), ], products: [ .library( name: "SnapshotTesting", targets: ["SnapshotTesting"] ), + .library( + name: "InlineSnapshotTesting", + targets: ["InlineSnapshotTesting"] + ), + ], + dependencies: [ + .package( + url: "https://github.com/apple/swift-syntax.git", + from: "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-09-05-a" + ) ], targets: [ - .target(name: "SnapshotTesting"), + .target( + name: "SnapshotTesting" + ), + .target( + name: "InlineSnapshotTesting", + dependencies: [ + "SnapshotTesting", + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + ] + ), + .testTarget( + name: "InlineSnapshotTestingTests", + dependencies: [ + "InlineSnapshotTesting" + ] + ), .testTarget( name: "SnapshotTestingTests", - dependencies: ["SnapshotTesting"], + dependencies: [ + "SnapshotTesting" + ], exclude: [ "__Fixtures__", - "__Snapshots__" + "__Snapshots__", ] - ) + ), ] ) diff --git a/README.md b/README.md index 3797539dc..e3bddd2a8 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,10 @@ Delightful Swift snapshot testing. - - ## Usage -Once [installed](#installation), _no additional configuration is required_. You can import the `SnapshotTesting` module and call the `assertSnapshot` function. +Once [installed](#installation), _no additional configuration is required_. You can import the +`SnapshotTesting` module and call the `assertSnapshot` function. ``` swift import SnapshotTesting @@ -28,17 +25,21 @@ class MyViewControllerTests: XCTestCase { } ``` -When an assertion first runs, a snapshot is automatically recorded to disk and the test will fail, printing out the file path of any newly-recorded reference. +When an assertion first runs, a snapshot is automatically recorded to disk and the test will fail, +printing out the file path of any newly-recorded reference. -> 🛑 failed - No reference was found on disk. Automatically recorded snapshot: … +> ❌ failed - No reference was found on disk. Automatically recorded snapshot: … > > open "…/MyAppTests/\_\_Snapshots\_\_/MyViewControllerTests/testMyViewController.png" > > Re-run "testMyViewController" to test against the newly-recorded snapshot. -Repeat test runs will load this reference and compare it with the runtime value. If they don't match, the test will fail and describe the difference. Failures can be inspected from Xcode's Report Navigator or by inspecting the file URLs of the failure. +Repeat test runs will load this reference and compare it with the runtime value. If they don't +match, the test will fail and describe the difference. Failures can be inspected from Xcode's Report +Navigator or by inspecting the file URLs of the failure. -You can record a new reference by setting the `record` parameter to `true` on the assertion or setting `isRecording` globally. +You can record a new reference by setting the `record` parameter to `true` on the assertion or +setting `isRecording` globally. ``` swift assertSnapshot(of: vc, as: .image, record: true) @@ -51,16 +52,20 @@ assertSnapshot(of: vc, as: .image) ## Snapshot Anything -While most snapshot testing libraries in the Swift community are limited to `UIImage`s of `UIView`s, SnapshotTesting can work with _any_ format of _any_ value on _any_ Swift platform! +While most snapshot testing libraries in the Swift community are limited to `UIImage`s of `UIView`s, +SnapshotTesting can work with _any_ format of _any_ value on _any_ Swift platform! -The `assertSnapshot` function accepts a value and any snapshot strategy that value supports. This means that a [view](Documentation/Available-Snapshot-Strategies.md#uiview) or [view controller](Documentation/Available-Snapshot-Strategies.md#uiviewcontroller) can be tested against an image representation _and_ against a textual representation of its properties and subview hierarchy. +The `assertSnapshot` function accepts a value and any snapshot strategy that value supports. This +means that a view or view controller can be tested against an image representation _and_ against a +textual representation of its properties and subview hierarchy. ``` swift assertSnapshot(of: vc, as: .image) assertSnapshot(of: vc, as: .recursiveDescription) ``` -View testing is [highly configurable](Documentation/Available-Snapshot-Strategies.md#uiviewcontroller). You can override trait collections (for specific size classes and content size categories) and generate device-agnostic snapshots, all from a single simulator. +View testing is highly configurable. You can override trait collections (for specific size classes +and content size categories) and generate device-agnostic snapshots, all from a single simulator. ``` swift assertSnapshot(of: vc, as: .image(on: .iPhoneSe)) @@ -76,9 +81,12 @@ assertSnapshot(of: vc, as: .image(on: .iPadMini(.portrait))) assertSnapshot(of: vc, as: .recursiveDescription(on: .iPadMini(.portrait))) ``` -> ⚠️ Warning: Snapshots must be compared using the exact same simulator that originally took the reference to avoid discrepancies between images. +> **Warning** +> Snapshots must be compared using the exact same simulator that originally took the reference to +> avoid discrepancies between images. -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. +Better yet, SnapshotTesting isn't limited to views and view controllers! There are a number of +available snapshot strategies to choose from. For example, you can snapshot test URL requests (_e.g._, those that your API client prepares). @@ -102,7 +110,8 @@ assertSnapshot(of: user, as: .json) assertSnapshot(of: user, as: .plist) // -// +// // // // bio @@ -115,7 +124,8 @@ assertSnapshot(of: user, as: .plist) // ``` -In fact, _[any](Documentation/Available-Snapshot-Strategies.md#any)_ value can be snapshot-tested by default using its [mirror](https://developer.apple.com/documentation/swift/mirror)! +In fact, _any_ value can be snapshot-tested by default using its +[mirror](https://developer.apple.com/documentation/swift/mirror)! ``` swift assertSnapshot(of: user, as: .dump) @@ -125,28 +135,35 @@ assertSnapshot(of: user, as: .dump) // - name: "Blobby" ``` -If your data can be represented as an image, text, or data, you can write a snapshot test for it! Check out [all of the snapshot strategies](Documentation/Available-Snapshot-Strategies.md) that ship with SnapshotTesting and [learn how to define your own custom strategies](Documentation/Defining-Custom-Snapshot-Strategies.md). +If your data can be represented as an image, text, or data, you can write a snapshot test for it! ## Installation ### Xcode -> ⚠️ Warning: By default, Xcode will try to add the SnapshotTesting package to your project's main application/framework target. Please ensure that SnapshotTesting is added to a _test_ target instead, as documented in the last step, below. +> **Warning** +> By default, Xcode will try to add the SnapshotTesting package to your project's main +> application/framework target. Please ensure that SnapshotTesting is added to a _test_ target +> instead, as documented in the last step, below. - 1. From the **File** menu, navigate through **Swift Packages** and select **Add Package Dependency…**. - 2. Enter package repository URL: `https://github.com/pointfreeco/swift-snapshot-testing` - 3. Confirm the version and let Xcode resolve the package - 4. On the final dialog, update SnapshotTesting's **Add to Target** column to a test target that will contain snapshot tests (if you have more than one test target, you can later add SnapshotTesting to them by manually linking the library in its build phase) + 1. From the **File** menu, navigate through **Swift Packages** and select + **Add Package Dependency…**. + 2. Enter package repository URL: `https://github.com/pointfreeco/swift-snapshot-testing`. + 3. Confirm the version and let Xcode resolve the package. + 4. On the final dialog, update SnapshotTesting's **Add to Target** column to a test target that + will contain snapshot tests (if you have more than one test target, you can later add + SnapshotTesting to them by manually linking the library in its build phase). ### Swift Package Manager -If you want to use SnapshotTesting in any other project that uses [SwiftPM](https://swift.org/package-manager/), add the package as a dependency in `Package.swift`: +If you want to use SnapshotTesting in any other project that uses +[SwiftPM](https://swift.org/package-manager/), add the package as a dependency in `Package.swift`: ```swift dependencies: [ .package( url: "https://github.com/pointfreeco/swift-snapshot-testing", - from: "1.10.0" + from: "1.12.0" ), ] ``` @@ -168,60 +185,96 @@ targets: [ ## Features - - [**Dozens of snapshot strategies**](Documentation/Available-Snapshot-Strategies.md). Snapshot testing isn't just for `UIView`s and `CALayer`s. Write snapshots against _any_ value. - - [**Write your own snapshot strategies**](Documentation/Defining-Custom-Snapshot-Strategies.md). If you can convert it to an image, string, data, or your own diffable format, you can snapshot test it! Build your own snapshot strategies from scratch or transform existing ones. - - **No configuration required.** Don't fuss with scheme settings and environment variables. Snapshots are automatically saved alongside your tests. + - [**Dozens of snapshot strategies**](Documentation/Available-Snapshot-Strategies.md). Snapshot + testing isn't just for `UIView`s and `CALayer`s. Write snapshots against _any_ value. + - [**Write your own snapshot strategies**](Documentation/Defining-Custom-Snapshot-Strategies.md). + If you can convert it to an image, string, data, or your own diffable format, you can snapshot + test it! Build your own snapshot strategies from scratch or transform existing ones. + - **No configuration required.** Don't fuss with scheme settings and environment variables. + Snapshots are automatically saved alongside your tests. - **More hands-off.** New snapshots are recorded whether `isRecording` mode is `true` or not. - **Subclass-free.** Assert from any XCTest case or Quick spec. - - **Device-agnostic snapshots.** Render views and view controllers for specific devices and trait collections from a single simulator. - - **First-class Xcode support.** Image differences are captured as XCTest attachments. Text differences are rendered in inline error messages. - - **Supports any platform that supports Swift.** Write snapshot tests for iOS, Linux, macOS, and tvOS. - - **SceneKit, SpriteKit, and WebKit support.** Most snapshot testing libraries don't support these view subclasses. - - **`Codable` support**. Snapshot encodable data structures into their [JSON](Documentation/Available-Snapshot-Strategies.md#json) and [property list](Documentation/Available-Snapshot-Strategies.md#plist) representations. - - **Custom diff tool integration**. Configure failure messages to print diff commands for [Kaleidoscope](https://kaleidoscope.app) (or your diff tool of choice). + - **Device-agnostic snapshots.** Render views and view controllers for specific devices and trait + collections from a single simulator. + - **First-class Xcode support.** Image differences are captured as XCTest attachments. Text + differences are rendered in inline error messages. + - **Supports any platform that supports Swift.** Write snapshot tests for iOS, Linux, macOS, and + tvOS. + - **SceneKit, SpriteKit, and WebKit support.** Most snapshot testing libraries don't support these + view subclasses. + - **`Codable` support**. Snapshot encodable data structures into their JSON and property list + representations. + - **Custom diff tool integration**. Configure failure messages to print diff commands for + [Kaleidoscope](https://kaleidoscope.app) (or your diff tool of choice). ``` swift SnapshotTesting.diffTool = "ksdiff" ``` ## Plug-ins - - [AccessibilitySnapshot](https://github.com/cashapp/AccessibilitySnapshot) adds easy regression testing for iOS accessibility. - - - [AccessibilitySnapshotColorBlindness](https://github.com/Sherlouk/AccessibilitySnapshotColorBlindness) adds snapshot strategies for color blindness simulation on iOS views, view controllers and images. + - [AccessibilitySnapshot](https://github.com/cashapp/AccessibilitySnapshot) adds easy regression + testing for iOS accessibility. + + - [AccessibilitySnapshotColorBlindness](https://github.com/Sherlouk/AccessibilitySnapshotColorBlindness) + adds snapshot strategies for color blindness simulation on iOS views, view controllers and images. - - [GRDBSnapshotTesting](https://github.com/SebastianOsinski/GRDBSnapshotTesting) adds snapshot strategy for testing SQLite database migrations made with [GRDB](https://github.com/groue/GRDB.swift). + - [GRDBSnapshotTesting](https://github.com/SebastianOsinski/GRDBSnapshotTesting) adds snapshot + strategy for testing SQLite database migrations made with [GRDB](https://github.com/groue/GRDB.swift). - - [Nimble-SnapshotTesting](https://github.com/tahirmt/Nimble-SnapshotTesting) adds [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting to be used by Swift Package Manager. + - [Nimble-SnapshotTesting](https://github.com/tahirmt/Nimble-SnapshotTesting) adds + [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting to be used by Swift + Package Manager. - - [Prefire](https://github.com/BarredEwe/Prefire) generating Snapshot Tests via [Swift Package Plugins](https://github.com/apple/swift-package-manager/blob/main/Documentation/Plugins.md) using SwiftUI `Preview` + - [Prefire](https://github.com/BarredEwe/Prefire) generating Snapshot Tests via + [Swift Package Plugins](https://github.com/apple/swift-package-manager/blob/main/Documentation/Plugins.md) + using SwiftUI `Preview` - - [PreviewSnapshots](https://github.com/doordash-oss/swiftui-preview-snapshots) share `View` configurations between SwiftUI Previews and snapshot tests and generate several snapshots with a single test assertion. + - [PreviewSnapshots](https://github.com/doordash-oss/swiftui-preview-snapshots) share `View` + configurations between SwiftUI Previews and snapshot tests and generate several snapshots with a + single test assertion. - - [swift-html](https://github.com/pointfreeco/swift-html) is a Swift DSL for type-safe, extensible, and transformable HTML documents and includes an `HtmlSnapshotTesting` module to snapshot test its HTML documents. + - [swift-html](https://github.com/pointfreeco/swift-html) is a Swift DSL for type-safe, + extensible, and transformable HTML documents and includes an `HtmlSnapshotTesting` module to + snapshot test its HTML documents. - - [swift-snapshot-testing-nimble](https://github.com/Killectro/swift-snapshot-testing-nimble) adds [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting. + - [swift-snapshot-testing-nimble](https://github.com/Killectro/swift-snapshot-testing-nimble) adds + [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting. - - [swift-snapshot-testing-stitch](https://github.com/Sherlouk/swift-snapshot-testing-stitch/) adds the ability to stitch multiple UIView's or UIViewController's together in a single test. + - [swift-snapshot-testing-stitch](https://github.com/Sherlouk/swift-snapshot-testing-stitch/) adds + the ability to stitch multiple UIView's or UIViewController's together in a single test. - - [SnapshotTestingDump](https://github.com/tahirmt/swift-snapshot-testing-dump) Adds support to use [swift-custom-dump](https://github.com/pointfreeco/swift-custom-dump/) by using `customDump` strategy for `Any` + - [SnapshotTestingDump](https://github.com/tahirmt/swift-snapshot-testing-dump) Adds support to + use [swift-custom-dump](https://github.com/pointfreeco/swift-custom-dump/) by using `customDump` + strategy for `Any` - - [SnapshotTestingHEIC](https://github.com/alexey1312/SnapshotTestingHEIC) adds image support using the HEIC storage format which reduces file sizes in comparison to PNG. + - [SnapshotTestingHEIC](https://github.com/alexey1312/SnapshotTestingHEIC) adds image support + using the HEIC storage format which reduces file sizes in comparison to PNG. -Have you written your own SnapshotTesting plug-in? [Add it here](https://github.com/pointfreeco/swift-snapshot-testing/edit/master/README.md) and submit a pull request! +Have you written your own SnapshotTesting plug-in? +[Add it here](https://github.com/pointfreeco/swift-snapshot-testing/edit/master/README.md) and +submit a pull request! ## Related Tools - - [`iOSSnapshotTestCase`](https://github.com/uber/ios-snapshot-test-case/) helped introduce screen shot testing to a broad audience in the iOS community. Experience with it inspired the creation of this library. + - [`iOSSnapshotTestCase`](https://github.com/uber/ios-snapshot-test-case/) helped introduce screen + shot testing to a broad audience in the iOS community. Experience with it inspired the creation + of this library. - - [Jest](https://jestjs.io) brought generalized snapshot testing to the JavaScript community with a polished user experience. Several features of this library (diffing, automatically capturing new snapshots) were directly influenced. + - [Jest](https://jestjs.io) brought generalized snapshot testing to the JavaScript community with + a polished user experience. Several features of this library (diffing, automatically capturing + new snapshots) were directly influenced. ## Learn More SnapshotTesting was designed with [witness-oriented programming](https://www.pointfree.co/episodes/ep39-witness-oriented-library-design). -This concept (and more) are explored thoroughly in a series of episodes on [Point-Free](https://www.pointfree.co), a video series exploring functional programming and Swift hosted by [Brandon Williams](https://twitter.com/mbrandonw) and [Stephen Celis](https://twitter.com/stephencelis). +This concept (and more) are explored thoroughly in a series of episodes on +[Point-Free](https://www.pointfree.co), a video series exploring functional programming and Swift +hosted by [Brandon Williams](https://twitter.com/mbrandonw) and +[Stephen Celis](https://twitter.com/stephencelis). -Witness-oriented programming and the design of this library was explored in the following [Point-Free](https://www.pointfree.co) episodes: +Witness-oriented programming and the design of this library was explored in the following +[Point-Free](https://www.pointfree.co) episodes: - [Episode 33](https://www.pointfree.co/episodes/ep33-protocol-witnesses-part-1): Protocol Witnesses: Part 1 - [Episode 34](https://www.pointfree.co/episodes/ep34-protocol-witnesses-part-1): Protocol Witnesses: Part 2 diff --git a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift new file mode 100644 index 000000000..f698177ed --- /dev/null +++ b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift @@ -0,0 +1,591 @@ +import Foundation +import SnapshotTesting +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest + +/// Asserts that a given value matches an inline string snapshot. +/// +/// See for more info. +/// +/// - Parameters: +/// - value: A value to compare against a snapshot. +/// - snapshotting: A strategy for snapshotting and comparing values. +/// - message: An optional description of the assertion, for inclusion in test results. +/// - timeout: The amount of time a snapshot must be generated in. +/// - syntaxDescriptor: An optional description of where the snapshot is inlined. This parameter +/// should be omitted unless you are writing a custom helper that calls this function under the +/// hood. See ``InlineSnapshotSyntaxDescriptor`` for more. +/// - expected: An optional closure that returns a previously generated snapshot. When omitted, +/// the library will automatically write a snapshot into your test file at the call sight of the +/// assertion. +/// - file: The file where the assertion occurs. The default is the filename of the test case +/// where you call this function. +/// - function: The function where the assertion occurs. The default is the name of the test +/// method where you call this function. +/// - line: The line where the assertion occurs. The default is the line number where you call +/// this function. +/// - column: The column where the assertion occurs. The default is the line column you call this +/// function. +public func assertInlineSnapshot( + of value: @autoclosure () throws -> Value, + as snapshotting: Snapshotting, + message: @autoclosure () -> String = "", + timeout: TimeInterval = 5, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor = InlineSnapshotSyntaxDescriptor(), + matches expected: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) { + let _: Void = installTestObserver + do { + var actual: String! + let expectation = XCTestExpectation() + try snapshotting.snapshot(value()).run { + actual = $0 + expectation.fulfill() + } + switch XCTWaiter.wait(for: [expectation], timeout: timeout) { + case .completed: + break + case .timedOut: + XCTFail( + """ + Exceeded timeout of \(timeout) seconds waiting for snapshot. + + This can happen when an asynchronously loaded value (like a network response) has not \ + loaded. If a timeout is unavoidable, consider setting the "timeout" parameter of + "assertInlineSnapshot" to a higher value. + """, + file: file, + line: line + ) + return + case .incorrectOrder, .interrupted, .invertedFulfillment: + XCTFail("Couldn't snapshot value", file: file, line: line) + return + @unknown default: + XCTFail("Couldn't snapshot value", file: file, line: line) + return + } + guard !isRecording, let expected = expected?() + else { + var failure: String + if syntaxDescriptor.trailingClosureLabel + == InlineSnapshotSyntaxDescriptor.defaultTrailingClosureLabel + { + failure = "Automatically recorded a new snapshot." + } else { + failure = """ + Automatically recorded a new snapshot for "\(syntaxDescriptor.trailingClosureLabel)". + """ + } + if let expected = expected?(), + let difference = snapshotting.diffing.diff(expected, actual)?.0 + { + failure += " Difference: …\n\n\(difference.indenting(by: 2))" + } + XCTFail( + """ + \(failure) + + Re-run "\(function)" to assert against the newly-recorded snapshot. + """, + file: file, + line: line + ) + inlineSnapshotState[File(path: file), default: []].append( + InlineSnapshot( + expected: expected?(), + actual: actual, + wasRecording: isRecording, + syntaxDescriptor: syntaxDescriptor, + function: "\(function)", + line: line, + column: column + ) + ) + return + } + guard let difference = snapshotting.diffing.diff(actual, expected)?.0 + else { return } + + let message = message() + syntaxDescriptor.fail( + """ + \(message.isEmpty ? "Snapshot did not match. Difference: …" : message) + + \(difference.indenting(by: 2)) + """, + file: file, + line: line, + column: column + ) + } catch { + XCTFail("Threw error: \(error)", file: file, line: line) + } +} + +/// A structure that describes the location of an inline snapshot. +/// +/// Provide this structure when defining custom snapshot functions that call +/// ``assertInlineSnapshot(of:as:message:timeout:syntaxDescriptor:matches:file:function:line:column:)`` +/// under the hood. +public struct InlineSnapshotSyntaxDescriptor: Hashable { + /// The default label describing an inline snapshot. + public static let defaultTrailingClosureLabel = "matches" + + /// The label of the trailing closure that returns the inline snapshot. + public var trailingClosureLabel: String + + /// The offset of the trailing closure that returns the inline snapshot, relative to the first + /// trailing closure. + /// + /// For example, a helper function with a few parameters and a single trailing closure has a + /// trailing closure offset of 0: + /// + /// ```swift + /// customInlineSnapshot(of: value, "Should match") { + /// // Inline snapshot... + /// } + /// ``` + /// + /// While a helper function with a trailing closure preceding the snapshot closure has an offset + /// of 1: + /// + /// ```swift + /// customInlineSnapshot("Should match") { + /// // Some other parameter... + /// } matches: { + /// // Inline snapshot... + /// } + /// ``` + public var trailingClosureOffset: Int + + /// Initializes an inline snapshot syntax descriptor. + /// + /// - Parameters: + /// - trailingClosureLabel: The label of the trailing closure that returns the inline snapshot. + /// - trailingClosureOffset: The offset of the trailing closure that returns the inline + /// snapshot, relative to the first trailing closure. + public init( + trailingClosureLabel: String = Self.defaultTrailingClosureLabel, + trailingClosureOffset: Int = 0 + ) { + self.trailingClosureLabel = trailingClosureLabel + self.trailingClosureOffset = trailingClosureOffset + } + + /// Generates a test failure immediately and unconditionally at the described trailing closure. + /// + /// This method will attempt to locate the line of the trailing closure described by this type and + /// call `XCTFail` with it. If the trailing closure cannot be located, the failure will be + /// associated with the given line, instead. + /// + /// - Parameters: + /// - message: An optional description of the assertion, for inclusion in test results. + /// - file: The file where the assertion occurs. The default is the filename of the test case + /// where you call `assertInlineSnapshot`. + /// - line: The line where the assertion occurs. The default is the line number where you call + /// `assertInlineSnapshot`. + /// - column: The column where the assertion occurs. The default is the column where you call + /// `assertInlineSnapshot`. + public func fail( + _ message: @autoclosure () -> String = "", + file: StaticString, + line: UInt, + column: UInt + ) { + var trailingClosureLine: Int? + if let testSource = try? testSource(file: File(path: file)) { + let visitor = SnapshotVisitor( + functionCallLine: Int(line), + functionCallColumn: Int(column), + sourceLocationConverter: testSource.sourceLocationConverter, + syntaxDescriptor: self + ) + visitor.walk(testSource.sourceFile) + trailingClosureLine = visitor.trailingClosureLine + } + XCTFail( + message(), + file: file, + line: trailingClosureLine.map(UInt.init) ?? line + ) + } +} + +// MARK: - Private + +private let installTestObserver: Void = { + final class InlineSnapshotObserver: NSObject, XCTestObservation { + func testBundleDidFinish(_ testBundle: Bundle) { + writeInlineSnapshots() + } + } + DispatchQueue.mainSync { + XCTestObservationCenter.shared.addTestObserver(InlineSnapshotObserver()) + } +}() + +extension DispatchQueue { + private static let key = DispatchSpecificKey() + private static let value: UInt8 = 0 + + fileprivate static func mainSync(execute block: () -> R) -> R { + Self.main.setSpecific(key: key, value: value) + if getSpecific(key: key) == value { + return block() + } else { + return main.sync(execute: block) + } + } +} + +private struct File: Hashable { + let path: StaticString + static func == (lhs: Self, rhs: Self) -> Bool { + "\(lhs.path)" == "\(rhs.path)" + } + func hash(into hasher: inout Hasher) { + hasher.combine("\(self.path)") + } +} + +private struct InlineSnapshot: Hashable { + var expected: String? + var actual: String + var wasRecording: Bool + var syntaxDescriptor: InlineSnapshotSyntaxDescriptor + var function: String + var line: UInt + var column: UInt +} + +private var inlineSnapshotState: [File: [InlineSnapshot]] = [:] + +private struct TestSource { + let source: String + let sourceFile: SourceFileSyntax + let sourceLocationConverter: SourceLocationConverter +} + +private func testSource(file: File) throws -> TestSource { + guard let testSource = testSourceCache[file] + else { + let filePath = "\(file.path)" + let source = try String(contentsOfFile: filePath) + let sourceFile = Parser.parse(source: source) + let sourceLocationConverter = SourceLocationConverter(fileName: filePath, tree: sourceFile) + let testSource = TestSource( + source: source, + sourceFile: sourceFile, + sourceLocationConverter: sourceLocationConverter + ) + testSourceCache[file] = testSource + return testSource + } + return testSource +} + +private var testSourceCache: [File: TestSource] = [:] + +private func writeInlineSnapshots() { + defer { inlineSnapshotState.removeAll() } + for (file, snapshots) in inlineSnapshotState { + let line = snapshots.first?.line ?? 1 + guard let testSource = try? testSource(file: file) + else { + fatalError("Couldn't load snapshot from disk", file: file.path, line: line) + } + let snapshotRewriter = SnapshotRewriter( + file: file, + snapshots: snapshots.sorted { + $0.line != $1.line + ? $0.line < $1.line + : $0.syntaxDescriptor.trailingClosureOffset < $1.syntaxDescriptor.trailingClosureOffset + }, + sourceLocationConverter: testSource.sourceLocationConverter + ) + let updatedSource = snapshotRewriter.visit(testSource.sourceFile).description + do { + if testSource.source != updatedSource { + try updatedSource.write(toFile: "\(file.path)", atomically: true, encoding: .utf8) + } + } catch { + fatalError("Threw error: \(error)", file: file.path, line: line) + } + } +} + +private final class SnapshotRewriter: SyntaxRewriter { + let file: File + var function: String? + let indent: String + let line: UInt? + var newRecordings: [(snapshot: InlineSnapshot, line: UInt)] = [] + var snapshots: [InlineSnapshot] + let sourceLocationConverter: SourceLocationConverter + let wasRecording: Bool + + init( + file: File, + snapshots: [InlineSnapshot], + sourceLocationConverter: SourceLocationConverter + ) { + self.file = file + self.line = snapshots.first?.line + self.wasRecording = snapshots.first?.wasRecording ?? isRecording + self.indent = String( + sourceLocationConverter.sourceLines + .first(where: { $0.first?.isWhitespace == true && $0 != "\n" })? + .prefix(while: { $0.isWhitespace }) + ?? " " + ) + self.snapshots = snapshots + self.sourceLocationConverter = sourceLocationConverter + } + + override func visit(_ functionCallExpr: FunctionCallExprSyntax) -> ExprSyntax { + let location = functionCallExpr.calledExpression + .endLocation(converter: self.sourceLocationConverter, afterTrailingTrivia: true) + let snapshots = self.snapshots.prefix { snapshot in + Int(snapshot.line) == location.line && Int(snapshot.column) == location.column + } + + guard !snapshots.isEmpty + else { return super.visit(functionCallExpr) } + + defer { self.snapshots.removeFirst(snapshots.count) } + + var functionCallExpr = functionCallExpr + for snapshot in snapshots { + guard snapshot.expected != snapshot.actual else { continue } + + self.function = + self.function + ?? functionCallExpr.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text + + let leadingTrivia = String( + self.sourceLocationConverter.sourceLines[Int(snapshot.line) - 1] + .prefix(while: { $0 == " " || $0 == "\t" }) + ) + let delimiter = String( + repeating: "#", count: snapshot.actual.hashCount(isMultiline: true) + ) + let leadingIndent = leadingTrivia + self.indent + let snapshotClosure = ClosureExprSyntax( + leftBrace: .leftBraceToken(trailingTrivia: .newline), + statements: CodeBlockItemListSyntax { + StringLiteralExprSyntax( + leadingTrivia: Trivia(stringLiteral: leadingIndent), + openingPounds: .rawStringPoundDelimiter(delimiter), + openingQuote: .multilineStringQuoteToken(trailingTrivia: .newline), + segments: [ + .stringSegment( + StringSegmentSyntax( + content: .stringSegment(snapshot.actual.indenting(with: leadingIndent)) + ) + ) + ], + closingQuote: .multilineStringQuoteToken( + leadingTrivia: .newline + Trivia(stringLiteral: leadingIndent) + ), + closingPounds: .rawStringPoundDelimiter(delimiter) + ) + }, + rightBrace: .rightBraceToken( + leadingTrivia: .newline + Trivia(stringLiteral: leadingTrivia) + ) + ) + + let arguments = functionCallExpr.arguments + let firstTrailingClosureOffset = + arguments + .enumerated() + .reversed() + .prefix(while: { $0.element.expression.is(ClosureExprSyntax.self) }) + .last? + .offset + ?? arguments.count + + let trailingClosureOffset = + firstTrailingClosureOffset + + snapshot.syntaxDescriptor.trailingClosureOffset + + let centeredTrailingClosureOffset = trailingClosureOffset - arguments.count + + switch centeredTrailingClosureOffset { + case ..<0: + let index = arguments.index(arguments.startIndex, offsetBy: trailingClosureOffset) + functionCallExpr.arguments[index].expression = ExprSyntax(snapshotClosure) + + case 0: + if snapshot.wasRecording || functionCallExpr.trailingClosure == nil { + functionCallExpr.rightParen?.trailingTrivia = .space + functionCallExpr.trailingClosure = snapshotClosure + } else { + fatalError() + } + + case 1...: + var newElement: MultipleTrailingClosureElementSyntax { + MultipleTrailingClosureElementSyntax( + label: TokenSyntax(stringLiteral: snapshot.syntaxDescriptor.trailingClosureLabel), + closure: snapshotClosure.with(\.leadingTrivia, snapshotClosure.leadingTrivia + .space) + ) + } + + if !functionCallExpr.additionalTrailingClosures.isEmpty, + let endIndex = functionCallExpr.additionalTrailingClosures.index( + functionCallExpr.additionalTrailingClosures.endIndex, + offsetBy: -1, + limitedBy: functionCallExpr.additionalTrailingClosures.startIndex + ), + let index = functionCallExpr.additionalTrailingClosures.index( + functionCallExpr.additionalTrailingClosures.startIndex, + offsetBy: centeredTrailingClosureOffset - 1, + limitedBy: endIndex + ) + { + if functionCallExpr.additionalTrailingClosures[index].label.text + == snapshot.syntaxDescriptor.trailingClosureLabel + { + if snapshot.wasRecording { + functionCallExpr.additionalTrailingClosures[index].closure = snapshotClosure + } + } else { + functionCallExpr.additionalTrailingClosures.insert( + newElement.with(\.trailingTrivia, .space), + at: index + ) + } + } else if centeredTrailingClosureOffset >= 1 { + if let index = functionCallExpr.additionalTrailingClosures.index( + functionCallExpr.additionalTrailingClosures.endIndex, + offsetBy: -1, + limitedBy: functionCallExpr.additionalTrailingClosures.startIndex + ) { + functionCallExpr.additionalTrailingClosures[index].trailingTrivia = .space + } else { + functionCallExpr.trailingClosure?.trailingTrivia = .space + } + functionCallExpr.additionalTrailingClosures.append(newElement) + } else { + fatalError() + } + + default: + fatalError() + } + } + return ExprSyntax(functionCallExpr) + } +} + +private final class SnapshotVisitor: SyntaxVisitor { + let functionCallColumn: Int + let functionCallLine: Int + let sourceLocationConverter: SourceLocationConverter + let syntaxDescriptor: InlineSnapshotSyntaxDescriptor + var trailingClosureLine: Int? + + init( + functionCallLine: Int, + functionCallColumn: Int, + sourceLocationConverter: SourceLocationConverter, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor + ) { + self.functionCallColumn = functionCallColumn + self.functionCallLine = functionCallLine + self.sourceLocationConverter = sourceLocationConverter + self.syntaxDescriptor = syntaxDescriptor + super.init(viewMode: .all) + } + + override func visit(_ functionCallExpr: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + let location = functionCallExpr.calledExpression + .endLocation(converter: self.sourceLocationConverter, afterTrailingTrivia: true) + guard + self.functionCallLine == location.line, + self.functionCallColumn == location.column + else { return .visitChildren } + + let arguments = functionCallExpr.arguments + let firstTrailingClosureOffset = + arguments + .enumerated() + .reversed() + .prefix(while: { $0.element.expression.is(ClosureExprSyntax.self) }) + .last? + .offset + ?? arguments.count + + let trailingClosureOffset = + firstTrailingClosureOffset + + self.syntaxDescriptor.trailingClosureOffset + + let centeredTrailingClosureOffset = trailingClosureOffset - arguments.count + + switch centeredTrailingClosureOffset { + case ..<0: + let index = arguments.index(arguments.startIndex, offsetBy: trailingClosureOffset) + self.trailingClosureLine = + arguments[index] + .startLocation(converter: self.sourceLocationConverter) + .line + + case 0: + self.trailingClosureLine = functionCallExpr.trailingClosure.map { + $0 + .startLocation(converter: self.sourceLocationConverter) + .line + } + + case 1...: + self.trailingClosureLine = + functionCallExpr.additionalTrailingClosures[ + functionCallExpr.additionalTrailingClosures.index( + functionCallExpr.additionalTrailingClosures.startIndex, + offsetBy: centeredTrailingClosureOffset - 1 + ) + ] + .startLocation(converter: self.sourceLocationConverter) + .line + default: + break + } + return .skipChildren + } +} + +extension String { + fileprivate func indenting(by count: Int) -> String { + self.indenting(with: String(repeating: " ", count: count)) + } + + fileprivate func indenting(with prefix: String) -> String { + guard !prefix.isEmpty else { return self } + return self.replacingOccurrences( + of: #"([^\n]+)"#, + with: "\(prefix)$1", + options: .regularExpression + ) + } + + fileprivate func hashCount(isMultiline: Bool) -> Int { + let (quote, offset) = isMultiline ? ("\"\"\"", 2) : ("\"", 0) + var substring = self[...] + var hashCount = self.contains(#"\"#) ? 1 : 0 + let pattern = "(\(quote)[#]*)" + while let range = substring.range(of: pattern, options: .regularExpression) { + let count = substring.distance(from: range.lowerBound, to: range.upperBound) - offset + hashCount = max(count, hashCount) + substring = substring[range.upperBound...] + } + return hashCount + } +} diff --git a/Sources/InlineSnapshotTesting/Documentation.docc/InlineSnapshotTesting.md b/Sources/InlineSnapshotTesting/Documentation.docc/InlineSnapshotTesting.md new file mode 100644 index 000000000..85c086631 --- /dev/null +++ b/Sources/InlineSnapshotTesting/Documentation.docc/InlineSnapshotTesting.md @@ -0,0 +1,75 @@ +# ``InlineSnapshotTesting`` + +Powerfully convenient snapshot testing. + +## Overview + +[Snapshot Testing][swift-snapshot-testing] writes the snapshots it generates directly to disk +alongside the test files. This makes for compact test cases with single line assertions... + +```swift +assertSnapshot(of: value, as: .json) +``` + +...but can make verification more cumbersome: one must find the corresponding file in order to +verify that it matches their expectation. In this case, if the above assertion is the second one in +a `testMySnapshot()` method in a `MySnapshotTests.swift` file, the snapshot will be found at: + +```sh +$ cat __Snapshots__/MySnapshotTests/testMySnapshot.2.json +{ + "id": 42, + "name": "Blob" +} +``` + +Inline Snapshot Testing offers an alternative approach by writing string snapshots directly into +the test file. This makes it easy to verify a snapshot test at any time, since the value and +snapshot sit next to each other in the assertion. One can `import InlineSnapshotTesting` and rewrite +the above assertion as: + +```swift +assertInlineSnapshot(of: value, as: .json) +``` + +And when the test is run, it will automatically insert the snapshot as a trailing closure to be used +by future test runs, and fail: + +```swift +assertInlineSnapshot(of: value, as: .json) { // ❌ + """ + { + "id": 42, + "name": "Blob" + } + """ +} +``` + +``` +❌ failed - Automatically recorded a new snapshot. + +Re-run "testMySnapshot" to test against the newly-recorded snapshot. +``` + +> Warning: When a snapshot is written into a test file, the undo history of the test file in Xcode +> will be lost. Be careful to avoid losing work, and commit often to version control. +> +> We would love for this to be fixed. Please [file feedback][apple-feedback] with Apple to improve +> things, or if you have an idea of how we can improve things from the library, please +> [start a discussion][discussions] or [open a pull request][pull-requests]. + +[apple-feedback]: https://www.apple.com/feedback/ +[discussions]: https://github.com/pointfreeco/swift-composable-architecture/discussions +[pull-requests]: https://github.com/pointfreeco/swift-composable-architecture/pulls +[swift-snapshot-testing]: https://github.com/pointfreeco/swift-snapshot-testing + +## Topics + +### Essentials + +- ``assertInlineSnapshot(of:as:message:timeout:syntaxDescriptor:matches:file:function:line:column:)`` + +### Writing a custom helper + +- ``InlineSnapshotSyntaxDescriptor`` diff --git a/Sources/InlineSnapshotTesting/Exports.swift b/Sources/InlineSnapshotTesting/Exports.swift new file mode 100644 index 000000000..44c34dda7 --- /dev/null +++ b/Sources/InlineSnapshotTesting/Exports.swift @@ -0,0 +1 @@ +@_exported import SnapshotTesting diff --git a/Sources/SnapshotTesting/AssertInlineSnapshot.swift b/Sources/SnapshotTesting/AssertInlineSnapshot.swift deleted file mode 100644 index 918b0f377..000000000 --- a/Sources/SnapshotTesting/AssertInlineSnapshot.swift +++ /dev/null @@ -1,327 +0,0 @@ -import XCTest - -/// Asserts that a given value matches a string literal. -/// -/// Note: Empty `reference` will be replaced automatically with generated output. -/// -/// Usage: -/// ``` -/// _assertInlineSnapshot(matching: value, as: .dump, with: """ -/// """) -/// ``` -/// -/// - Parameters: -/// - value: A value to compare against a reference. -/// - snapshotting: A strategy for serializing, deserializing, and comparing values. -/// - recording: Whether or not to record a new reference. -/// - timeout: The amount of time a snapshot must be generated in. -/// - reference: The expected output of snapshotting. -/// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called. -/// - testName: The name of the test in which failure occurred. Defaults to the function name of the test case in which this function was called. -/// - line: The line number on which failure occurred. Defaults to the line number on which this function was called. -public func _assertInlineSnapshot( - matching value: @autoclosure () throws -> Value, - as snapshotting: Snapshotting, - record recording: Bool = false, - timeout: TimeInterval = 5, - with reference: String, - file: StaticString = #file, - testName: String = #function, - line: UInt = #line - ) { - - let failure = _verifyInlineSnapshot( - matching: try value(), - as: snapshotting, - record: recording, - timeout: timeout, - with: reference, - file: file, - testName: testName, - line: line - ) - guard let message = failure else { return } - XCTFail(message, file: file, line: line) -} - -/// Verifies that a given value matches a string literal. -/// -/// Third party snapshot assert helpers can be built on top of this function. Simply invoke `verifyInlineSnapshot` with your own arguments, and then invoke `XCTFail` with the string returned if it is non-`nil`. -/// -/// - Parameters: -/// - value: A value to compare against a reference. -/// - snapshotting: A strategy for serializing, deserializing, and comparing values. -/// - recording: Whether or not to record a new reference. -/// - timeout: The amount of time a snapshot must be generated in. -/// - reference: The expected output of snapshotting. -/// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called. -/// - testName: The name of the test in which failure occurred. Defaults to the function name of the test case in which this function was called. -/// - line: The line number on which failure occurred. Defaults to the line number on which this function was called. -/// - Returns: A failure message or, if the value matches, nil. -public func _verifyInlineSnapshot( - matching value: @autoclosure () throws -> Value, - as snapshotting: Snapshotting, - record recording: Bool = false, - timeout: TimeInterval = 5, - with reference: String, - file: StaticString = #file, - testName: String = #function, - line: UInt = #line - ) - -> String? { - - let recording = recording || isRecording - - do { - let tookSnapshot = XCTestExpectation(description: "Took snapshot") - var optionalDiffable: String? - snapshotting.snapshot(try value()).run { b in - optionalDiffable = b - tookSnapshot.fulfill() - } - let result = XCTWaiter.wait(for: [tookSnapshot], timeout: timeout) - switch result { - case .completed: - break - case .timedOut: - return """ - Exceeded timeout of \(timeout) seconds waiting for snapshot. - - This can happen when an asynchronously rendered view (like a web view) has not loaded. \ - Ensure that every subview of the view hierarchy has loaded to avoid timeouts, or, if a \ - timeout is unavoidable, consider setting the "timeout" parameter of "assertSnapshot" to \ - a higher value. - """ - case .incorrectOrder, .invertedFulfillment, .interrupted: - return "Couldn't snapshot value" - @unknown default: - return "Couldn't snapshot value" - } - - let trimmingChars = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "\u{FEFF}")) - guard let diffable = optionalDiffable?.trimmingCharacters(in: trimmingChars) else { - return "Couldn't snapshot value" - } - - let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines) - - // Always perform diff, and return early on success! - guard let (failure, attachments) = snapshotting.diffing.diff(trimmedReference, diffable) else { - return nil - } - - // If that diff failed, we either record or fail. - if recording || trimmedReference.isEmpty { - let fileName = "\(file)" - let sourceCodeFilePath = URL(fileURLWithPath: fileName, isDirectory: false) - let sourceCode = try String(contentsOf: sourceCodeFilePath) - var newRecordings = recordings - - let modifiedSource = try writeInlineSnapshot( - &newRecordings, - Context( - sourceCode: sourceCode, - diffable: diffable, - fileName: fileName, - lineIndex: Int(line) - ) - ).sourceCode - - try modifiedSource - .data(using: String.Encoding.utf8)? - .write(to: sourceCodeFilePath) - - if newRecordings != recordings { - recordings = newRecordings - /// If no other recording has been made, then fail! - return """ - No reference was found inline. Automatically recorded snapshot. - - Re-run "\(sanitizePathComponent(testName))" to test against the newly-recorded snapshot. - """ - } else { - /// There is already an failure in this file, - /// and we don't want to write to the wrong place. - return nil - } - } - - /// Did not successfully record, so we will fail. - if !attachments.isEmpty { - #if !os(Linux) && !os(Windows) - if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { - XCTContext.runActivity(named: "Attached Failure Diff") { activity in - attachments.forEach { - activity.add($0) - } - } - } - #endif - } - - return """ - Snapshot does not match reference. - - \(failure.trimmingCharacters(in: .whitespacesAndNewlines)) - """ - - } catch { - return error.localizedDescription - } -} - -internal typealias Recordings = [String: [FileRecording]] - -internal struct Context { - let sourceCode: String - let diffable: String - let fileName: String - // First line of a file is line 1 (as with the #line macro) - let lineIndex: Int - - func setSourceCode(_ newSourceCode: String) -> Context { - return Context( - sourceCode: newSourceCode, - diffable: diffable, - fileName: fileName, - lineIndex: lineIndex - ) - } -} - -internal func writeInlineSnapshot( - _ recordings: inout Recordings, - _ context: Context -) throws -> Context { - var sourceCodeLines = context.sourceCode - .split(separator: "\n", omittingEmptySubsequences: false) - - let otherRecordings = recordings[context.fileName, default: []] - let otherRecordingsAboveThisLine = otherRecordings.filter { $0.line < context.lineIndex } - let offsetStartIndex = otherRecordingsAboveThisLine.reduce(context.lineIndex) { $0 + $1.difference } - let functionLineIndex = offsetStartIndex - 1 - var lineCountDifference = 0 - - // Convert `""` to multi-line literal - if sourceCodeLines[functionLineIndex].hasSuffix(emptyStringLiteralWithCloseBrace) { - // Convert: - // _assertInlineSnapshot(matching: value, as: .dump, with: "") - // to: - // _assertInlineSnapshot(matching: value, as: .dump, with: """ - // """) - var functionCallLine = sourceCodeLines.remove(at: functionLineIndex) - functionCallLine.removeLast(emptyStringLiteralWithCloseBrace.count) - let indentText = indentation(of: functionCallLine) - sourceCodeLines.insert(contentsOf: [ - functionCallLine + multiLineStringLiteralTerminator, - indentText + multiLineStringLiteralTerminator + ")", - ] as [String.SubSequence], at: functionLineIndex) - lineCountDifference += 1 - } - - /// If they haven't got a multi-line literal by now, then just fail. - guard sourceCodeLines[functionLineIndex].hasSuffix(multiLineStringLiteralTerminator) else { - struct InlineError: LocalizedError { - var errorDescription: String? { - return """ -To use inline snapshots, please convert the "with" argument to a multi-line literal. -""" - } - } - throw InlineError() - } - - /// Find the end of multi-line literal and replace contents with recording. - if let multiLineLiteralEndIndex = sourceCodeLines[offsetStartIndex...].firstIndex(where: { $0.hasClosingMultilineStringDelimiter() }) { - - let diffableLines = context.diffable.split(separator: "\n") - - // Add #'s to the multiline string literal if needed - let numberSigns: String - if context.diffable.hasEscapedSpecialCharactersLiteral() { - numberSigns = String(repeating: "#", count: context.diffable.numberOfNumberSignsNeeded()) - } else if nil != diffableLines.first(where: { $0.endsInBackslash() }) { - // We want to avoid \ being interpreted as an escaped newline in the recorded inline snapshot - numberSigns = "#" - } else { - numberSigns = "" - } - let multiLineStringLiteralTerminatorPre = numberSigns + multiLineStringLiteralTerminator - let multiLineStringLiteralTerminatorPost = multiLineStringLiteralTerminator + numberSigns - - // Update opening (#...)""" - sourceCodeLines[functionLineIndex].replaceFirstOccurrence( - of: extendedOpeningStringDelimitersPattern, - with: multiLineStringLiteralTerminatorPre - ) - - // Update closing """(#...) - sourceCodeLines[multiLineLiteralEndIndex].replaceFirstOccurrence( - of: extendedClosingStringDelimitersPattern, - with: multiLineStringLiteralTerminatorPost - ) - - /// Convert actual value to Lines to insert - let indentText = indentation(of: sourceCodeLines[multiLineLiteralEndIndex]) - let newDiffableLines = context.diffable - .split(separator: "\n", omittingEmptySubsequences: false) - .map { Substring(indentText + $0) } - lineCountDifference += newDiffableLines.count - (multiLineLiteralEndIndex - offsetStartIndex) - - let fileRecording = FileRecording(line: context.lineIndex, difference: lineCountDifference) - - /// Insert the lines - sourceCodeLines.replaceSubrange(offsetStartIndex..(of str: S) -> String { - var count = 0 - for char in str { - guard char == " " else { break } - count += 1 - } - return String(repeating: " ", count: count) -} - -fileprivate extension Substring { - mutating func replaceFirstOccurrence(of pattern: String, with newString: String) { - let newString = replacingOccurrences(of: pattern, with: newString, options: .regularExpression) - self = Substring(newString) - } - - func hasOpeningMultilineStringDelimiter() -> Bool { - return range(of: extendedOpeningStringDelimitersPattern, options: .regularExpression) != nil - } - - func hasClosingMultilineStringDelimiter() -> Bool { - return range(of: extendedClosingStringDelimitersPattern, options: .regularExpression) != nil - } - - func endsInBackslash() -> Bool { - if let lastChar = last { - return lastChar == Character(#"\"#) - } - return false - } -} - -private let emptyStringLiteralWithCloseBrace = "\"\")" -private let multiLineStringLiteralTerminator = "\"\"\"" -private let extendedOpeningStringDelimitersPattern = #"#{0,}\"\"\""# -private let extendedClosingStringDelimitersPattern = ##"\"\"\"#{0,}"## - -// When we modify a file, the line numbers reported by the compiler through #line are no longer -// accurate. With the FileRecording values we keep track of we modify the files so we can adjust -// line numbers. -private var recordings: Recordings = [:] diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/SnapshotTesting/AssertSnapshot.swift index fedc4e030..8d4479e27 100644 --- a/Sources/SnapshotTesting/AssertSnapshot.swift +++ b/Sources/SnapshotTesting/AssertSnapshot.swift @@ -1,14 +1,18 @@ import XCTest -/// Enhances failure messages with a command line diff tool expression that can be copied and pasted into a terminal. +/// Enhances failure messages with a command line diff tool expression that can be copied and pasted +/// into a terminal. /// -/// diffTool = "ksdiff" +/// ```swift +/// diffTool = "ksdiff" +/// ``` public var diffTool: String? = nil /// Whether or not to record all new references. public var isRecording = false /// Whether or not to record all new references. +/// /// Due to a name clash in Xcode 12, this has been renamed to `isRecording`. @available(*, deprecated, renamed: "isRecording") public var record: Bool { @@ -58,12 +62,16 @@ public func assertSnapshot( /// /// - Parameters: /// - value: A value to compare against a reference. -/// - strategies: A dictionary of names and strategies for serializing, deserializing, and comparing values. +/// - strategies: A dictionary of names and strategies for serializing, deserializing, and +/// comparing values. /// - recording: Whether or not to record a new reference. /// - timeout: The amount of time a snapshot must be generated in. -/// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called. -/// - testName: The name of the test in which failure occurred. Defaults to the function name of the test case in which this function was called. -/// - line: The line number on which failure occurred. Defaults to the line number on which this function was called. +/// - file: The file in which failure occurred. Defaults to the file name of the test case in +/// which this function was called. +/// - testName: The name of the test in which failure occurred. Defaults to the function name of +/// the test case in which this function was called. +/// - line: The line number on which failure occurred. Defaults to the line number on which this +/// function was called. public func assertSnapshots( of value: @autoclosure () throws -> Value, as strategies: [String: Snapshotting], @@ -94,9 +102,12 @@ public func assertSnapshots( /// - strategies: An array of strategies for serializing, deserializing, and comparing values. /// - recording: Whether or not to record a new reference. /// - timeout: The amount of time a snapshot must be generated in. -/// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called. -/// - testName: The name of the test in which failure occurred. Defaults to the function name of the test case in which this function was called. -/// - line: The line number on which failure occurred. Defaults to the line number on which this function was called. +/// - file: The file in which failure occurred. Defaults to the file name of the test case in +/// which this function was called. +/// - testName: The name of the test in which failure occurred. Defaults to the function name of +/// the test case in which this function was called. +/// - line: The line number on which failure occurred. Defaults to the line number on which this +/// function was called. public func assertSnapshots( of value: @autoclosure () throws -> Value, as strategies: [Snapshotting], @@ -121,33 +132,38 @@ public func assertSnapshots( /// Verifies that a given value matches a reference on disk. /// -/// Third party snapshot assert helpers can be built on top of this function. Simply invoke `verifySnapshot` with your own arguments, and then invoke `XCTFail` with the string returned if it is non-`nil`. For example, if you want the snapshot directory to be determined by an environment variable, you can create your own assert helper like so: +/// Third party snapshot assert helpers can be built on top of this function. Simply invoke +/// `verifySnapshot` with your own arguments, and then invoke `XCTFail` with the string returned if +/// it is non-`nil`. For example, if you want the snapshot directory to be determined by an +/// environment variable, you can create your own assert helper like so: /// -/// public func myAssertSnapshot( -/// of value: @autoclosure () throws -> Value, -/// as snapshotting: Snapshotting, -/// named name: String? = nil, -/// record recording: Bool = false, -/// timeout: TimeInterval = 5, -/// file: StaticString = #file, -/// testName: String = #function, -/// line: UInt = #line -/// ) { +/// ```swift +/// public func myAssertSnapshot( +/// of value: @autoclosure () throws -> Value, +/// as snapshotting: Snapshotting, +/// named name: String? = nil, +/// record recording: Bool = false, +/// timeout: TimeInterval = 5, +/// file: StaticString = #file, +/// testName: String = #function, +/// line: UInt = #line +/// ) { /// -/// let snapshotDirectory = ProcessInfo.processInfo.environment["SNAPSHOT_REFERENCE_DIR"]! + "/" + #file -/// let failure = verifySnapshot( -/// of: value, -/// as: snapshotting, -/// named: name, -/// record: recording, -/// snapshotDirectory: snapshotDirectory, -/// timeout: timeout, -/// file: file, -/// testName: testName -/// ) -/// guard let message = failure else { return } -/// XCTFail(message, file: file, line: line) -/// } +/// let snapshotDirectory = ProcessInfo.processInfo.environment["SNAPSHOT_REFERENCE_DIR"]! + "/" + #file +/// let failure = verifySnapshot( +/// of: value, +/// as: snapshotting, +/// named: name, +/// record: recording, +/// snapshotDirectory: snapshotDirectory, +/// timeout: timeout, +/// file: file, +/// testName: testName +/// ) +/// guard let message = failure else { return } +/// XCTFail(message, file: file, line: line) +/// } +/// ``` /// /// - Parameters: /// - value: A value to compare against a reference. @@ -177,121 +193,126 @@ public func verifySnapshot( line: UInt = #line ) -> String? { - CleanCounterBetweenTestCases.registerIfNeeded() - let recording = recording || isRecording - - do { - let fileUrl = URL(fileURLWithPath: "\(file)", isDirectory: false) - let fileName = fileUrl.deletingPathExtension().lastPathComponent - - let snapshotDirectoryUrl = snapshotDirectory.map { URL(fileURLWithPath: $0, isDirectory: true) } - ?? fileUrl - .deletingLastPathComponent() - .appendingPathComponent("__Snapshots__") - .appendingPathComponent(fileName) - - let identifier: String - if let name = name { - identifier = sanitizePathComponent(name) - } else { - let counter = counterQueue.sync { () -> Int in - let key = snapshotDirectoryUrl.appendingPathComponent(testName) - counterMap[key, default: 0] += 1 - return counterMap[key]! - } - identifier = String(counter) + CleanCounterBetweenTestCases.registerIfNeeded() + let recording = recording || isRecording + + do { + let fileUrl = URL(fileURLWithPath: "\(file)", isDirectory: false) + let fileName = fileUrl.deletingPathExtension().lastPathComponent + + let snapshotDirectoryUrl = + snapshotDirectory.map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? fileUrl + .deletingLastPathComponent() + .appendingPathComponent("__Snapshots__") + .appendingPathComponent(fileName) + + let identifier: String + if let name = name { + identifier = sanitizePathComponent(name) + } else { + let counter = counterQueue.sync { () -> Int in + let key = snapshotDirectoryUrl.appendingPathComponent(testName) + counterMap[key, default: 0] += 1 + return counterMap[key]! } + identifier = String(counter) + } - let testName = sanitizePathComponent(testName) - let snapshotFileUrl = snapshotDirectoryUrl - .appendingPathComponent("\(testName).\(identifier)") - .appendingPathExtension(snapshotting.pathExtension ?? "") - let fileManager = FileManager.default - try fileManager.createDirectory(at: snapshotDirectoryUrl, withIntermediateDirectories: true) - - let tookSnapshot = XCTestExpectation(description: "Took snapshot") - var optionalDiffable: Format? - snapshotting.snapshot(try value()).run { b in - optionalDiffable = b - tookSnapshot.fulfill() - } - let result = XCTWaiter.wait(for: [tookSnapshot], timeout: timeout) - switch result { - case .completed: - break - case .timedOut: - return """ - Exceeded timeout of \(timeout) seconds waiting for snapshot. - - This can happen when an asynchronously rendered view (like a web view) has not loaded. \ - Ensure that every subview of the view hierarchy has loaded to avoid timeouts, or, if a \ - timeout is unavoidable, consider setting the "timeout" parameter of "assertSnapshot" to \ - a higher value. - """ - case .incorrectOrder, .invertedFulfillment, .interrupted: - return "Couldn't snapshot value" - @unknown default: - return "Couldn't snapshot value" - } + let testName = sanitizePathComponent(testName) + let snapshotFileUrl = + snapshotDirectoryUrl + .appendingPathComponent("\(testName).\(identifier)") + .appendingPathExtension(snapshotting.pathExtension ?? "") + let fileManager = FileManager.default + try fileManager.createDirectory(at: snapshotDirectoryUrl, withIntermediateDirectories: true) + + let tookSnapshot = XCTestExpectation(description: "Took snapshot") + var optionalDiffable: Format? + snapshotting.snapshot(try value()).run { b in + optionalDiffable = b + tookSnapshot.fulfill() + } + let result = XCTWaiter.wait(for: [tookSnapshot], timeout: timeout) + switch result { + case .completed: + break + case .timedOut: + return """ + Exceeded timeout of \(timeout) seconds waiting for snapshot. - guard var diffable = optionalDiffable else { - return "Couldn't snapshot value" - } - - guard !recording, fileManager.fileExists(atPath: snapshotFileUrl.path) else { - try snapshotting.diffing.toData(diffable).write(to: snapshotFileUrl) - #if !os(Linux) && !os(Windows) + This can happen when an asynchronously rendered view (like a web view) has not loaded. \ + Ensure that every subview of the view hierarchy has loaded to avoid timeouts, or, if a \ + timeout is unavoidable, consider setting the "timeout" parameter of "assertSnapshot" to \ + a higher value. + """ + case .incorrectOrder, .invertedFulfillment, .interrupted: + return "Couldn't snapshot value" + @unknown default: + return "Couldn't snapshot value" + } + + guard var diffable = optionalDiffable else { + return "Couldn't snapshot value" + } + + guard !recording, fileManager.fileExists(atPath: snapshotFileUrl.path) else { + try snapshotting.diffing.toData(diffable).write(to: snapshotFileUrl) + #if !os(Linux) && !os(Windows) if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { XCTContext.runActivity(named: "Attached Recorded Snapshot") { activity in let attachment = XCTAttachment(contentsOfFile: snapshotFileUrl) activity.add(attachment) } } - #endif + #endif - return recording - ? """ - Record mode is on. Turn record mode off and re-run "\(testName)" to test against the newly-recorded snapshot. + return recording + ? """ + Record mode is on. Automatically recorded snapshot: … - open "\(snapshotFileUrl.absoluteString)" + open "\(snapshotFileUrl.absoluteString)" - Recorded snapshot: … - """ - : """ - No reference was found on disk. Automatically recorded snapshot: … + Turn record mode off and re-run "\(testName)" to assert against the newly-recorded snapshot + """ + : """ + No reference was found on disk. Automatically recorded snapshot: … - open "\(snapshotFileUrl.path)" + open "\(snapshotFileUrl.absoluteString)" - Re-run "\(testName)" to test against the newly-recorded snapshot. - """ - } + Re-run "\(testName)" to assert against the newly-recorded snapshot. + """ + } - let data = try Data(contentsOf: snapshotFileUrl) - let reference = snapshotting.diffing.fromData(data) + let data = try Data(contentsOf: snapshotFileUrl) + let reference = snapshotting.diffing.fromData(data) - #if os(iOS) || os(tvOS) + #if os(iOS) || os(tvOS) // If the image generation fails for the diffable part and the reference was empty, use the reference if let localDiff = diffable as? UIImage, - let refImage = reference as? UIImage, - localDiff.size == .zero && refImage.size == .zero { + let refImage = reference as? UIImage, + localDiff.size == .zero && refImage.size == .zero + { diffable = reference } - #endif - - guard let (failure, attachments) = snapshotting.diffing.diff(reference, diffable) else { - return nil - } + #endif - let artifactsUrl = URL( - fileURLWithPath: ProcessInfo.processInfo.environment["SNAPSHOT_ARTIFACTS"] ?? NSTemporaryDirectory(), isDirectory: true - ) - let artifactsSubUrl = artifactsUrl.appendingPathComponent(fileName) - try fileManager.createDirectory(at: artifactsSubUrl, withIntermediateDirectories: true) - let failedSnapshotFileUrl = artifactsSubUrl.appendingPathComponent(snapshotFileUrl.lastPathComponent) - try snapshotting.diffing.toData(diffable).write(to: failedSnapshotFileUrl) + guard let (failure, attachments) = snapshotting.diffing.diff(reference, diffable) else { + return nil + } - if !attachments.isEmpty { - #if !os(Linux) && !os(Windows) + let artifactsUrl = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["SNAPSHOT_ARTIFACTS"] + ?? NSTemporaryDirectory(), isDirectory: true + ) + let artifactsSubUrl = artifactsUrl.appendingPathComponent(fileName) + try fileManager.createDirectory(at: artifactsSubUrl, withIntermediateDirectories: true) + let failedSnapshotFileUrl = artifactsSubUrl.appendingPathComponent( + snapshotFileUrl.lastPathComponent) + try snapshotting.diffing.toData(diffable).write(to: failedSnapshotFileUrl) + + if !attachments.isEmpty { + #if !os(Linux) && !os(Windows) if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { XCTContext.runActivity(named: "Attached Failure Diff") { activity in attachments.forEach { @@ -299,39 +320,40 @@ public func verifySnapshot( } } } - #endif - } + #endif + } - let diffMessage = diffTool - .map { "\($0) \"\(snapshotFileUrl.path)\" \"\(failedSnapshotFileUrl.path)\"" } - ?? """ - @\(minus) - "\(snapshotFileUrl.absoluteString)" - @\(plus) - "\(failedSnapshotFileUrl.absoluteString)" + let diffMessage = + diffTool + .map { "\($0) \"\(snapshotFileUrl.path)\" \"\(failedSnapshotFileUrl.path)\"" } + ?? """ + @\(minus) + "\(snapshotFileUrl.absoluteString)" + @\(plus) + "\(failedSnapshotFileUrl.absoluteString)" - To configure output for a custom diff tool, like Kaleidoscope: + To configure output for a custom diff tool, like Kaleidoscope: - SnapshotTesting.diffTool = "ksdiff" - """ + SnapshotTesting.diffTool = "ksdiff" + """ - let failureMessage: String - if let name = name { - failureMessage = "Snapshot \"\(name)\" does not match reference." - } else { - failureMessage = "Snapshot does not match reference." - } + let failureMessage: String + if let name = name { + failureMessage = "Snapshot \"\(name)\" does not match reference." + } else { + failureMessage = "Snapshot does not match reference." + } - return """ + return """ \(failureMessage) \(diffMessage) \(failure.trimmingCharacters(in: .whitespacesAndNewlines)) """ - } catch { - return error.localizedDescription - } + } catch { + return error.localizedDescription + } } // MARK: - Private @@ -340,28 +362,30 @@ private let counterQueue = DispatchQueue(label: "co.pointfree.SnapshotTesting.co private var counterMap: [URL: Int] = [:] func sanitizePathComponent(_ string: String) -> String { - return string + return + string .replacingOccurrences(of: "\\W+", with: "-", options: .regularExpression) .replacingOccurrences(of: "^-|-$", with: "", options: .regularExpression) } // We need to clean counter between tests executions in order to support test-iterations. private class CleanCounterBetweenTestCases: NSObject, XCTestObservation { - private static var registered = false - private static var registerQueue = DispatchQueue(label: "co.pointfree.SnapshotTesting.testObserver") - - static func registerIfNeeded() { - registerQueue.sync { - if !registered { - registered = true - XCTestObservationCenter.shared.addTestObserver(CleanCounterBetweenTestCases()) - } + private static var registered = false + private static var registerQueue = DispatchQueue( + label: "co.pointfree.SnapshotTesting.testObserver") + + static func registerIfNeeded() { + registerQueue.sync { + if !registered { + registered = true + XCTestObservationCenter.shared.addTestObserver(CleanCounterBetweenTestCases()) } } + } - func testCaseDidFinish(_ testCase: XCTestCase) { - counterQueue.sync { - counterMap = [:] - } + func testCaseDidFinish(_ testCase: XCTestCase) { + counterQueue.sync { + counterMap = [:] } + } } diff --git a/Sources/SnapshotTesting/Async.swift b/Sources/SnapshotTesting/Async.swift index 427d5a503..bf1ddc295 100644 --- a/Sources/SnapshotTesting/Async.swift +++ b/Sources/SnapshotTesting/Async.swift @@ -2,13 +2,17 @@ /// /// Snapshot strategies may utilize this type to create snapshots in an asynchronous fashion. /// -/// For example, WebKit's `WKWebView` offers a callback-based API for taking image snapshots (`takeSnapshot`). `Async` allows us to build a value that can pass its callback along to the scope in which the image has been created. +/// For example, WebKit's `WKWebView` offers a callback-based API for taking image snapshots +/// (`takeSnapshot`). `Async` allows us to build a value that can pass its callback along to the +/// scope in which the image has been created. /// -/// Async { callback in -/// webView.takeSnapshot(with: nil) { image, error in -/// callback(image!) -/// } -/// } +/// ```swift +/// Async { callback in +/// webView.takeSnapshot(with: nil) { image, error in +/// callback(image!) +/// } +/// } +/// ``` public struct Async { public let run: (@escaping (Value) -> Void) -> Void @@ -28,12 +32,12 @@ public struct Async { self.init { callback in callback(value) } } - /// Transforms an Async into an Async with a function `(Value) -> NewValue`. + /// Transforms an `Async` into an `Async` with a function `(Value) -> NewValue`. /// - /// - Parameter f: A transformation to apply to the value wrapped by the async value. - public func map(_ f: @escaping (Value) -> NewValue) -> Async { - return .init { callback in - self.run { a in callback(f(a)) } + /// - Parameter transform: A transformation to apply to the value wrapped by the async value. + public func map(_ transform: @escaping (Value) -> NewValue) -> Async { + .init { callback in + self.run { value in callback(transform(value)) } } } } diff --git a/Sources/SnapshotTesting/Common/Internal.swift b/Sources/SnapshotTesting/Common/Internal.swift index af77913bd..865f24b07 100644 --- a/Sources/SnapshotTesting/Common/Internal.swift +++ b/Sources/SnapshotTesting/Common/Internal.swift @@ -1,11 +1,11 @@ #if os(macOS) -import Cocoa -typealias Image = NSImage -typealias ImageView = NSImageView -typealias View = NSView + import Cocoa + typealias Image = NSImage + typealias ImageView = NSImageView + typealias View = NSView #elseif os(iOS) || os(tvOS) -import UIKit -typealias Image = UIImage -typealias ImageView = UIImageView -typealias View = UIView + import UIKit + typealias Image = UIImage + typealias ImageView = UIImageView + typealias View = UIView #endif diff --git a/Sources/SnapshotTesting/Common/PlistEncoder.swift b/Sources/SnapshotTesting/Common/PlistEncoder.swift index 589557e14..a9b4bf902 100644 --- a/Sources/SnapshotTesting/Common/PlistEncoder.swift +++ b/Sources/SnapshotTesting/Common/PlistEncoder.swift @@ -4,16 +4,17 @@ import Foundation extension DecodingError { - internal static func _typeMismatch(at path: [CodingKey], expectation: Any.Type, reality: Any) -> DecodingError { - let description = "Expected to decode \(expectation) but found \(type(of: reality)) instead." - return .typeMismatch(expectation, Context(codingPath: path, debugDescription: description)) - } + internal static func _typeMismatch(at path: [CodingKey], expectation: Any.Type, reality: Any) + -> DecodingError + { + let description = "Expected to decode \(expectation) but found \(type(of: reality)) instead." + return .typeMismatch(expectation, Context(codingPath: path, debugDescription: description)) + } } let kCFBooleanTrue = NSNumber(booleanLiteral: true) let kCFBooleanFalse = NSNumber(booleanLiteral: false) - //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project @@ -33,572 +34,653 @@ let kCFBooleanFalse = NSNumber(booleanLiteral: false) /// `PropertyListEncoder` facilitates the encoding of `Encodable` values into property lists. open class PropertyListEncoder { - // MARK: - Options - - /// The output format to write the property list data in. Defaults to `.binary`. - open var outputFormat: PropertyListSerialization.PropertyListFormat = .binary - - /// Contextual user-provided information for use during encoding. - open var userInfo: [CodingUserInfoKey : Any] = [:] - - /// Options set on the top-level encoder to pass down the encoding hierarchy. - fileprivate struct _Options { - let outputFormat: PropertyListSerialization.PropertyListFormat - let userInfo: [CodingUserInfoKey : Any] - } - - /// The options set on the top-level encoder. - fileprivate var options: _Options { - return _Options(outputFormat: outputFormat, userInfo: userInfo) - } - - // MARK: - Constructing a Property List Encoder - - /// Initializes `self` with default strategies. - public init() {} - - // MARK: - Encoding Values - - /// Encodes the given top-level value and returns its property list representation. - /// - /// - parameter value: The value to encode. - /// - returns: A new `Data` value containing the encoded property list data. - /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value is encountered during encoding, and the encoding strategy is `.throw`. - /// - throws: An error if any value throws an error during encoding. - open func encode(_ value: Value) throws -> Data { - let topLevel = try encodeToTopLevelContainer(value) - if topLevel is NSNumber { - throw EncodingError.invalidValue(value, - EncodingError.Context(codingPath: [], - debugDescription: "Top-level \(Value.self) encoded as number property list fragment.")) - } else if topLevel is NSString { - throw EncodingError.invalidValue(value, - EncodingError.Context(codingPath: [], - debugDescription: "Top-level \(Value.self) encoded as string property list fragment.")) - } else if topLevel is NSDate { - throw EncodingError.invalidValue(value, - EncodingError.Context(codingPath: [], - debugDescription: "Top-level \(Value.self) encoded as date property list fragment.")) - } - - do { - return try PropertyListSerialization.data(fromPropertyList: topLevel, format: self.outputFormat, options: 0) - } catch { - throw EncodingError.invalidValue(value, - EncodingError.Context(codingPath: [], debugDescription: "Unable to encode the given top-level value as a property list", underlyingError: error)) - } - } - - /// Encodes the given top-level value and returns its plist-type representation. - /// - /// - parameter value: The value to encode. - /// - returns: A new top-level array or dictionary representing the value. - /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value is encountered during encoding, and the encoding strategy is `.throw`. - /// - throws: An error if any value throws an error during encoding. - internal func encodeToTopLevelContainer(_ value: Value) throws -> Any { - let encoder = _PlistEncoder(options: self.options) - guard let topLevel = try encoder.box_(value) else { - throw EncodingError.invalidValue(value, - EncodingError.Context(codingPath: [], - debugDescription: "Top-level \(Value.self) did not encode any values.")) - } - - return topLevel - } + // MARK: - Options + + /// The output format to write the property list data in. Defaults to `.binary`. + open var outputFormat: PropertyListSerialization.PropertyListFormat = .binary + + /// Contextual user-provided information for use during encoding. + open var userInfo: [CodingUserInfoKey: Any] = [:] + + /// Options set on the top-level encoder to pass down the encoding hierarchy. + fileprivate struct _Options { + let outputFormat: PropertyListSerialization.PropertyListFormat + let userInfo: [CodingUserInfoKey: Any] + } + + /// The options set on the top-level encoder. + fileprivate var options: _Options { + return _Options(outputFormat: outputFormat, userInfo: userInfo) + } + + // MARK: - Constructing a Property List Encoder + + /// Initializes `self` with default strategies. + public init() {} + + // MARK: - Encoding Values + + /// Encodes the given top-level value and returns its property list representation. + /// + /// - parameter value: The value to encode. + /// - returns: A new `Data` value containing the encoded property list data. + /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value is encountered during encoding, and the encoding strategy is `.throw`. + /// - throws: An error if any value throws an error during encoding. + open func encode(_ value: Value) throws -> Data { + let topLevel = try encodeToTopLevelContainer(value) + if topLevel is NSNumber { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: "Top-level \(Value.self) encoded as number property list fragment.")) + } else if topLevel is NSString { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: "Top-level \(Value.self) encoded as string property list fragment.")) + } else if topLevel is NSDate { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: "Top-level \(Value.self) encoded as date property list fragment.")) + } + + do { + return try PropertyListSerialization.data( + fromPropertyList: topLevel, format: self.outputFormat, options: 0) + } catch { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: "Unable to encode the given top-level value as a property list", + underlyingError: error)) + } + } + + /// Encodes the given top-level value and returns its plist-type representation. + /// + /// - parameter value: The value to encode. + /// - returns: A new top-level array or dictionary representing the value. + /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value is encountered during encoding, and the encoding strategy is `.throw`. + /// - throws: An error if any value throws an error during encoding. + internal func encodeToTopLevelContainer(_ value: Value) throws -> Any { + let encoder = _PlistEncoder(options: self.options) + guard let topLevel = try encoder.box_(value) else { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: "Top-level \(Value.self) did not encode any values.")) + } + + return topLevel + } } // MARK: - _PlistEncoder -fileprivate class _PlistEncoder : Encoder { - // MARK: Properties - - /// The encoder's storage. - fileprivate var storage: _PlistEncodingStorage - - /// Options set on the top-level encoder. - fileprivate let options: PropertyListEncoder._Options - - /// The path to the current point in encoding. - fileprivate(set) public var codingPath: [CodingKey] - - /// Contextual user-provided information for use during encoding. - public var userInfo: [CodingUserInfoKey : Any] { - return self.options.userInfo - } - - // MARK: - Initialization - - /// Initializes `self` with the given top-level encoder options. - fileprivate init(options: PropertyListEncoder._Options, codingPath: [CodingKey] = []) { - self.options = options - self.storage = _PlistEncodingStorage() - self.codingPath = codingPath - } - - /// Returns whether a new element can be encoded at this coding path. - /// - /// `true` if an element has not yet been encoded at this coding path; `false` otherwise. - fileprivate var canEncodeNewValue: Bool { - // Every time a new value gets encoded, the key it's encoded for is pushed onto the coding path (even if it's a nil key from an unkeyed container). - // At the same time, every time a container is requested, a new value gets pushed onto the storage stack. - // If there are more values on the storage stack than on the coding path, it means the value is requesting more than one container, which violates the precondition. - // - // This means that anytime something that can request a new container goes onto the stack, we MUST push a key onto the coding path. - // Things which will not request containers do not need to have the coding path extended for them (but it doesn't matter if it is, because they will not reach here). - return self.storage.count == self.codingPath.count - } - - // MARK: - Encoder Methods - public func container(keyedBy: Key.Type) -> KeyedEncodingContainer { - // If an existing keyed container was already requested, return that one. - let topContainer: NSMutableDictionary - if self.canEncodeNewValue { - // We haven't yet pushed a container at this level; do so here. - topContainer = self.storage.pushKeyedContainer() - } else { - guard let container = self.storage.containers.last as? NSMutableDictionary else { - preconditionFailure("Attempt to push new keyed encoding container when already previously encoded at this path.") - } - - topContainer = container - } - - let container = _PlistKeyedEncodingContainer(referencing: self, codingPath: self.codingPath, wrapping: topContainer) - return KeyedEncodingContainer(container) - } - - public func unkeyedContainer() -> UnkeyedEncodingContainer { - // If an existing unkeyed container was already requested, return that one. - let topContainer: NSMutableArray - if self.canEncodeNewValue { - // We haven't yet pushed a container at this level; do so here. - topContainer = self.storage.pushUnkeyedContainer() - } else { - guard let container = self.storage.containers.last as? NSMutableArray else { - preconditionFailure("Attempt to push new unkeyed encoding container when already previously encoded at this path.") - } - - topContainer = container - } - - return _PlistUnkeyedEncodingContainer(referencing: self, codingPath: self.codingPath, wrapping: topContainer) - } - - public func singleValueContainer() -> SingleValueEncodingContainer { - return self - } +private class _PlistEncoder: Encoder { + // MARK: Properties + + /// The encoder's storage. + fileprivate var storage: _PlistEncodingStorage + + /// Options set on the top-level encoder. + fileprivate let options: PropertyListEncoder._Options + + /// The path to the current point in encoding. + fileprivate(set) public var codingPath: [CodingKey] + + /// Contextual user-provided information for use during encoding. + public var userInfo: [CodingUserInfoKey: Any] { + return self.options.userInfo + } + + // MARK: - Initialization + + /// Initializes `self` with the given top-level encoder options. + fileprivate init(options: PropertyListEncoder._Options, codingPath: [CodingKey] = []) { + self.options = options + self.storage = _PlistEncodingStorage() + self.codingPath = codingPath + } + + /// Returns whether a new element can be encoded at this coding path. + /// + /// `true` if an element has not yet been encoded at this coding path; `false` otherwise. + fileprivate var canEncodeNewValue: Bool { + // Every time a new value gets encoded, the key it's encoded for is pushed onto the coding path (even if it's a nil key from an unkeyed container). + // At the same time, every time a container is requested, a new value gets pushed onto the storage stack. + // If there are more values on the storage stack than on the coding path, it means the value is requesting more than one container, which violates the precondition. + // + // This means that anytime something that can request a new container goes onto the stack, we MUST push a key onto the coding path. + // Things which will not request containers do not need to have the coding path extended for them (but it doesn't matter if it is, because they will not reach here). + return self.storage.count == self.codingPath.count + } + + // MARK: - Encoder Methods + public func container(keyedBy: Key.Type) -> KeyedEncodingContainer { + // If an existing keyed container was already requested, return that one. + let topContainer: NSMutableDictionary + if self.canEncodeNewValue { + // We haven't yet pushed a container at this level; do so here. + topContainer = self.storage.pushKeyedContainer() + } else { + guard let container = self.storage.containers.last as? NSMutableDictionary else { + preconditionFailure( + "Attempt to push new keyed encoding container when already previously encoded at this path." + ) + } + + topContainer = container + } + + let container = _PlistKeyedEncodingContainer( + referencing: self, codingPath: self.codingPath, wrapping: topContainer) + return KeyedEncodingContainer(container) + } + + public func unkeyedContainer() -> UnkeyedEncodingContainer { + // If an existing unkeyed container was already requested, return that one. + let topContainer: NSMutableArray + if self.canEncodeNewValue { + // We haven't yet pushed a container at this level; do so here. + topContainer = self.storage.pushUnkeyedContainer() + } else { + guard let container = self.storage.containers.last as? NSMutableArray else { + preconditionFailure( + "Attempt to push new unkeyed encoding container when already previously encoded at this path." + ) + } + + topContainer = container + } + + return _PlistUnkeyedEncodingContainer( + referencing: self, codingPath: self.codingPath, wrapping: topContainer) + } + + public func singleValueContainer() -> SingleValueEncodingContainer { + return self + } } // MARK: - Encoding Storage and Containers -fileprivate struct _PlistEncodingStorage { - // MARK: Properties +private struct _PlistEncodingStorage { + // MARK: Properties - /// The container stack. - /// Elements may be any one of the plist types (NSNumber, NSString, NSDate, NSArray, NSDictionary). - private(set) fileprivate var containers: [NSObject] = [] + /// The container stack. + /// Elements may be any one of the plist types (NSNumber, NSString, NSDate, NSArray, NSDictionary). + private(set) fileprivate var containers: [NSObject] = [] - // MARK: - Initialization + // MARK: - Initialization - /// Initializes `self` with no containers. - fileprivate init() {} + /// Initializes `self` with no containers. + fileprivate init() {} - // MARK: - Modifying the Stack + // MARK: - Modifying the Stack - fileprivate var count: Int { - return self.containers.count - } + fileprivate var count: Int { + return self.containers.count + } - fileprivate mutating func pushKeyedContainer() -> NSMutableDictionary { - let dictionary = NSMutableDictionary() - self.containers.append(dictionary) - return dictionary - } + fileprivate mutating func pushKeyedContainer() -> NSMutableDictionary { + let dictionary = NSMutableDictionary() + self.containers.append(dictionary) + return dictionary + } - fileprivate mutating func pushUnkeyedContainer() -> NSMutableArray { - let array = NSMutableArray() - self.containers.append(array) - return array - } + fileprivate mutating func pushUnkeyedContainer() -> NSMutableArray { + let array = NSMutableArray() + self.containers.append(array) + return array + } - fileprivate mutating func push(container: __owned NSObject) { - self.containers.append(container) - } + fileprivate mutating func push(container: __owned NSObject) { + self.containers.append(container) + } - fileprivate mutating func popContainer() -> NSObject { - precondition(!self.containers.isEmpty, "Empty container stack.") - return self.containers.popLast()! - } + fileprivate mutating func popContainer() -> NSObject { + precondition(!self.containers.isEmpty, "Empty container stack.") + return self.containers.popLast()! + } } // MARK: - Encoding Containers -fileprivate struct _PlistKeyedEncodingContainer : KeyedEncodingContainerProtocol { - typealias Key = K - - // MARK: Properties - - /// A reference to the encoder we're writing to. - private let encoder: _PlistEncoder - - /// A reference to the container we're writing to. - private let container: NSMutableDictionary - - /// The path of coding keys taken to get to this point in encoding. - private(set) public var codingPath: [CodingKey] - - // MARK: - Initialization - - /// Initializes `self` with the given references. - fileprivate init(referencing encoder: _PlistEncoder, codingPath: [CodingKey], wrapping container: NSMutableDictionary) { - self.encoder = encoder - self.codingPath = codingPath - self.container = container - } - - // MARK: - KeyedEncodingContainerProtocol Methods - - public mutating func encodeNil(forKey key: Key) throws { self.container[key.stringValue] = _plistNullNSString } - public mutating func encode(_ value: Bool, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: Int, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: Int8, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: Int16, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: Int32, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: Int64, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: UInt, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: UInt8, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: UInt16, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: UInt32, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: UInt64, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: String, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: Float, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - public mutating func encode(_ value: Double, forKey key: Key) throws { self.container[key.stringValue] = self.encoder.box(value) } - - public mutating func encode(_ value: T, forKey key: Key) throws { - self.encoder.codingPath.append(key) - defer { self.encoder.codingPath.removeLast() } - self.container[key.stringValue] = try self.encoder.box(value) - } - - public mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { - let dictionary = NSMutableDictionary() - self.container[key.stringValue] = dictionary - - self.codingPath.append(key) - defer { self.codingPath.removeLast() } - - let container = _PlistKeyedEncodingContainer(referencing: self.encoder, codingPath: self.codingPath, wrapping: dictionary) - return KeyedEncodingContainer(container) - } - - public mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { - let array = NSMutableArray() - self.container[key.stringValue] = array - - self.codingPath.append(key) - defer { self.codingPath.removeLast() } - return _PlistUnkeyedEncodingContainer(referencing: self.encoder, codingPath: self.codingPath, wrapping: array) - } - - public mutating func superEncoder() -> Encoder { - return _PlistReferencingEncoder(referencing: self.encoder, at: _PlistKey.super, wrapping: self.container) - } - - public mutating func superEncoder(forKey key: Key) -> Encoder { - return _PlistReferencingEncoder(referencing: self.encoder, at: key, wrapping: self.container) - } +private struct _PlistKeyedEncodingContainer: KeyedEncodingContainerProtocol { + typealias Key = K + + // MARK: Properties + + /// A reference to the encoder we're writing to. + private let encoder: _PlistEncoder + + /// A reference to the container we're writing to. + private let container: NSMutableDictionary + + /// The path of coding keys taken to get to this point in encoding. + private(set) public var codingPath: [CodingKey] + + // MARK: - Initialization + + /// Initializes `self` with the given references. + fileprivate init( + referencing encoder: _PlistEncoder, codingPath: [CodingKey], + wrapping container: NSMutableDictionary + ) { + self.encoder = encoder + self.codingPath = codingPath + self.container = container + } + + // MARK: - KeyedEncodingContainerProtocol Methods + + public mutating func encodeNil(forKey key: Key) throws { + self.container[key.stringValue] = _plistNullNSString + } + public mutating func encode(_ value: Bool, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: Int, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: Int8, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: Int16, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: Int32, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: Int64, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: UInt, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: UInt8, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: UInt16, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: UInt32, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: UInt64, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: String, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: Float, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + public mutating func encode(_ value: Double, forKey key: Key) throws { + self.container[key.stringValue] = self.encoder.box(value) + } + + public mutating func encode(_ value: T, forKey key: Key) throws { + self.encoder.codingPath.append(key) + defer { self.encoder.codingPath.removeLast() } + self.container[key.stringValue] = try self.encoder.box(value) + } + + public mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) + -> KeyedEncodingContainer + { + let dictionary = NSMutableDictionary() + self.container[key.stringValue] = dictionary + + self.codingPath.append(key) + defer { self.codingPath.removeLast() } + + let container = _PlistKeyedEncodingContainer( + referencing: self.encoder, codingPath: self.codingPath, wrapping: dictionary) + return KeyedEncodingContainer(container) + } + + public mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + let array = NSMutableArray() + self.container[key.stringValue] = array + + self.codingPath.append(key) + defer { self.codingPath.removeLast() } + return _PlistUnkeyedEncodingContainer( + referencing: self.encoder, codingPath: self.codingPath, wrapping: array) + } + + public mutating func superEncoder() -> Encoder { + return _PlistReferencingEncoder( + referencing: self.encoder, at: _PlistKey.super, wrapping: self.container) + } + + public mutating func superEncoder(forKey key: Key) -> Encoder { + return _PlistReferencingEncoder(referencing: self.encoder, at: key, wrapping: self.container) + } } -fileprivate struct _PlistUnkeyedEncodingContainer : UnkeyedEncodingContainer { - // MARK: Properties - - /// A reference to the encoder we're writing to. - private let encoder: _PlistEncoder - - /// A reference to the container we're writing to. - private let container: NSMutableArray - - /// The path of coding keys taken to get to this point in encoding. - private(set) public var codingPath: [CodingKey] - - /// The number of elements encoded into the container. - public var count: Int { - return self.container.count - } - - // MARK: - Initialization - - /// Initializes `self` with the given references. - fileprivate init(referencing encoder: _PlistEncoder, codingPath: [CodingKey], wrapping container: NSMutableArray) { - self.encoder = encoder - self.codingPath = codingPath - self.container = container - } - - // MARK: - UnkeyedEncodingContainer Methods - - public mutating func encodeNil() throws { self.container.add(_plistNullNSString) } - public mutating func encode(_ value: Bool) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: Int) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: Int8) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: Int16) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: Int32) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: Int64) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: UInt) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: UInt8) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: UInt16) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: UInt32) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: UInt64) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: Float) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: Double) throws { self.container.add(self.encoder.box(value)) } - public mutating func encode(_ value: String) throws { self.container.add(self.encoder.box(value)) } - - public mutating func encode(_ value: T) throws { - self.encoder.codingPath.append(_PlistKey(index: self.count)) - defer { self.encoder.codingPath.removeLast() } - self.container.add(try self.encoder.box(value)) - } - - public mutating func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer { - self.codingPath.append(_PlistKey(index: self.count)) - defer { self.codingPath.removeLast() } - - let dictionary = NSMutableDictionary() - self.container.add(dictionary) - - let container = _PlistKeyedEncodingContainer(referencing: self.encoder, codingPath: self.codingPath, wrapping: dictionary) - return KeyedEncodingContainer(container) - } - - public mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { - self.codingPath.append(_PlistKey(index: self.count)) - defer { self.codingPath.removeLast() } - - let array = NSMutableArray() - self.container.add(array) - return _PlistUnkeyedEncodingContainer(referencing: self.encoder, codingPath: self.codingPath, wrapping: array) - } - - public mutating func superEncoder() -> Encoder { - return _PlistReferencingEncoder(referencing: self.encoder, at: self.container.count, wrapping: self.container) - } +private struct _PlistUnkeyedEncodingContainer: UnkeyedEncodingContainer { + // MARK: Properties + + /// A reference to the encoder we're writing to. + private let encoder: _PlistEncoder + + /// A reference to the container we're writing to. + private let container: NSMutableArray + + /// The path of coding keys taken to get to this point in encoding. + private(set) public var codingPath: [CodingKey] + + /// The number of elements encoded into the container. + public var count: Int { + return self.container.count + } + + // MARK: - Initialization + + /// Initializes `self` with the given references. + fileprivate init( + referencing encoder: _PlistEncoder, codingPath: [CodingKey], wrapping container: NSMutableArray + ) { + self.encoder = encoder + self.codingPath = codingPath + self.container = container + } + + // MARK: - UnkeyedEncodingContainer Methods + + public mutating func encodeNil() throws { self.container.add(_plistNullNSString) } + public mutating func encode(_ value: Bool) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: Int) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: Int8) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: Int16) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: Int32) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: Int64) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: UInt) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: UInt8) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: UInt16) throws { + self.container.add(self.encoder.box(value)) + } + public mutating func encode(_ value: UInt32) throws { + self.container.add(self.encoder.box(value)) + } + public mutating func encode(_ value: UInt64) throws { + self.container.add(self.encoder.box(value)) + } + public mutating func encode(_ value: Float) throws { self.container.add(self.encoder.box(value)) } + public mutating func encode(_ value: Double) throws { + self.container.add(self.encoder.box(value)) + } + public mutating func encode(_ value: String) throws { + self.container.add(self.encoder.box(value)) + } + + public mutating func encode(_ value: T) throws { + self.encoder.codingPath.append(_PlistKey(index: self.count)) + defer { self.encoder.codingPath.removeLast() } + self.container.add(try self.encoder.box(value)) + } + + public mutating func nestedContainer(keyedBy keyType: NestedKey.Type) + -> KeyedEncodingContainer + { + self.codingPath.append(_PlistKey(index: self.count)) + defer { self.codingPath.removeLast() } + + let dictionary = NSMutableDictionary() + self.container.add(dictionary) + + let container = _PlistKeyedEncodingContainer( + referencing: self.encoder, codingPath: self.codingPath, wrapping: dictionary) + return KeyedEncodingContainer(container) + } + + public mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + self.codingPath.append(_PlistKey(index: self.count)) + defer { self.codingPath.removeLast() } + + let array = NSMutableArray() + self.container.add(array) + return _PlistUnkeyedEncodingContainer( + referencing: self.encoder, codingPath: self.codingPath, wrapping: array) + } + + public mutating func superEncoder() -> Encoder { + return _PlistReferencingEncoder( + referencing: self.encoder, at: self.container.count, wrapping: self.container) + } } -extension _PlistEncoder : SingleValueEncodingContainer { - // MARK: - SingleValueEncodingContainer Methods - - private func assertCanEncodeNewValue() { - precondition(self.canEncodeNewValue, "Attempt to encode value through single value container when previously value already encoded.") - } - - public func encodeNil() throws { - assertCanEncodeNewValue() - self.storage.push(container: _plistNullNSString) - } - - public func encode(_ value: Bool) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: Int) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: Int8) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: Int16) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: Int32) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: Int64) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: UInt) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: UInt8) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: UInt16) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: UInt32) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: UInt64) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: String) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: Float) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: Double) throws { - assertCanEncodeNewValue() - self.storage.push(container: self.box(value)) - } - - public func encode(_ value: T) throws { - assertCanEncodeNewValue() - try self.storage.push(container: self.box(value)) - } +extension _PlistEncoder: SingleValueEncodingContainer { + // MARK: - SingleValueEncodingContainer Methods + + private func assertCanEncodeNewValue() { + precondition( + self.canEncodeNewValue, + "Attempt to encode value through single value container when previously value already encoded." + ) + } + + public func encodeNil() throws { + assertCanEncodeNewValue() + self.storage.push(container: _plistNullNSString) + } + + public func encode(_ value: Bool) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: Int) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: Int8) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: Int16) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: Int32) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: Int64) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: UInt) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: UInt8) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: UInt16) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: UInt32) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: UInt64) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: String) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: Float) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: Double) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + public func encode(_ value: T) throws { + assertCanEncodeNewValue() + try self.storage.push(container: self.box(value)) + } } // MARK: - Concrete Value Representations extension _PlistEncoder { - /// Returns the given value boxed in a container appropriate for pushing onto the container stack. - fileprivate func box(_ value: Bool) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: Int) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: Int8) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: Int16) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: Int32) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: Int64) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: UInt) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: UInt8) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: UInt16) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: UInt32) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: UInt64) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: Float) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: Double) -> NSObject { return NSNumber(value: value) } - fileprivate func box(_ value: String) -> NSObject { return NSString(string: value) } - - fileprivate func box(_ value: T) throws -> NSObject { - return try self.box_(value) ?? NSDictionary() - } - - fileprivate func box_(_ value: T) throws -> NSObject? { - if T.self == Date.self || T.self == NSDate.self { - // PropertyListSerialization handles NSDate directly. - return (value as! NSDate) - } else if T.self == Data.self || T.self == NSData.self { - // PropertyListSerialization handles NSData directly. - return (value as! NSData) - } - - // The value should request a container from the _PlistEncoder. - let depth = self.storage.count - do { - try value.encode(to: self) - } catch let error { - // If the value pushed a container before throwing, pop it back off to restore state. - if self.storage.count > depth { - let _ = self.storage.popContainer() - } - - throw error - } - - // The top container should be a new container. - guard self.storage.count > depth else { - return nil - } - - return self.storage.popContainer() - } + /// Returns the given value boxed in a container appropriate for pushing onto the container stack. + fileprivate func box(_ value: Bool) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: Int) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: Int8) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: Int16) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: Int32) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: Int64) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: UInt) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: UInt8) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: UInt16) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: UInt32) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: UInt64) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: Float) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: Double) -> NSObject { return NSNumber(value: value) } + fileprivate func box(_ value: String) -> NSObject { return NSString(string: value) } + + fileprivate func box(_ value: T) throws -> NSObject { + return try self.box_(value) ?? NSDictionary() + } + + fileprivate func box_(_ value: T) throws -> NSObject? { + if T.self == Date.self || T.self == NSDate.self { + // PropertyListSerialization handles NSDate directly. + return (value as! NSDate) + } else if T.self == Data.self || T.self == NSData.self { + // PropertyListSerialization handles NSData directly. + return (value as! NSData) + } + + // The value should request a container from the _PlistEncoder. + let depth = self.storage.count + do { + try value.encode(to: self) + } catch let error { + // If the value pushed a container before throwing, pop it back off to restore state. + if self.storage.count > depth { + let _ = self.storage.popContainer() + } + + throw error + } + + // The top container should be a new container. + guard self.storage.count > depth else { + return nil + } + + return self.storage.popContainer() + } } // MARK: - _PlistReferencingEncoder /// _PlistReferencingEncoder is a special subclass of _PlistEncoder which has its own storage, but references the contents of a different encoder. /// It's used in superEncoder(), which returns a new encoder for encoding a superclass -- the lifetime of the encoder should not escape the scope it's created in, but it doesn't necessarily know when it's done being used (to write to the original container). -fileprivate class _PlistReferencingEncoder : _PlistEncoder { - // MARK: Reference types. +private class _PlistReferencingEncoder: _PlistEncoder { + // MARK: Reference types. - /// The type of container we're referencing. - private enum Reference { - /// Referencing a specific index in an array container. - case array(NSMutableArray, Int) + /// The type of container we're referencing. + private enum Reference { + /// Referencing a specific index in an array container. + case array(NSMutableArray, Int) - /// Referencing a specific key in a dictionary container. - case dictionary(NSMutableDictionary, String) - } + /// Referencing a specific key in a dictionary container. + case dictionary(NSMutableDictionary, String) + } - // MARK: - Properties + // MARK: - Properties - /// The encoder we're referencing. - private let encoder: _PlistEncoder + /// The encoder we're referencing. + private let encoder: _PlistEncoder - /// The container reference itself. - private let reference: Reference + /// The container reference itself. + private let reference: Reference - // MARK: - Initialization + // MARK: - Initialization - /// Initializes `self` by referencing the given array container in the given encoder. - fileprivate init(referencing encoder: _PlistEncoder, at index: Int, wrapping array: NSMutableArray) { - self.encoder = encoder - self.reference = .array(array, index) - super.init(options: encoder.options, codingPath: encoder.codingPath) + /// Initializes `self` by referencing the given array container in the given encoder. + fileprivate init( + referencing encoder: _PlistEncoder, at index: Int, wrapping array: NSMutableArray + ) { + self.encoder = encoder + self.reference = .array(array, index) + super.init(options: encoder.options, codingPath: encoder.codingPath) - self.codingPath.append(_PlistKey(index: index)) - } + self.codingPath.append(_PlistKey(index: index)) + } - /// Initializes `self` by referencing the given dictionary container in the given encoder. - fileprivate init(referencing encoder: _PlistEncoder, at key: CodingKey, wrapping dictionary: NSMutableDictionary) { - self.encoder = encoder - self.reference = .dictionary(dictionary, key.stringValue) - super.init(options: encoder.options, codingPath: encoder.codingPath) + /// Initializes `self` by referencing the given dictionary container in the given encoder. + fileprivate init( + referencing encoder: _PlistEncoder, at key: CodingKey, wrapping dictionary: NSMutableDictionary + ) { + self.encoder = encoder + self.reference = .dictionary(dictionary, key.stringValue) + super.init(options: encoder.options, codingPath: encoder.codingPath) - self.codingPath.append(key) - } + self.codingPath.append(key) + } - // MARK: - Coding Path Operations + // MARK: - Coding Path Operations - fileprivate override var canEncodeNewValue: Bool { - // With a regular encoder, the storage and coding path grow together. - // A referencing encoder, however, inherits its parents coding path, as well as the key it was created for. - // We have to take this into account. - return self.storage.count == self.codingPath.count - self.encoder.codingPath.count - 1 - } + fileprivate override var canEncodeNewValue: Bool { + // With a regular encoder, the storage and coding path grow together. + // A referencing encoder, however, inherits its parents coding path, as well as the key it was created for. + // We have to take this into account. + return self.storage.count == self.codingPath.count - self.encoder.codingPath.count - 1 + } - // MARK: - Deinitialization + // MARK: - Deinitialization - // Finalizes `self` by writing the contents of our storage to the referenced encoder's storage. - deinit { - let value: Any - switch self.storage.count { - case 0: value = NSDictionary() - case 1: value = self.storage.popContainer() - default: fatalError("Referencing encoder deallocated with multiple containers on stack.") - } + // Finalizes `self` by writing the contents of our storage to the referenced encoder's storage. + deinit { + let value: Any + switch self.storage.count { + case 0: value = NSDictionary() + case 1: value = self.storage.popContainer() + default: fatalError("Referencing encoder deallocated with multiple containers on stack.") + } - switch self.reference { - case .array(let array, let index): - array.insert(value, at: index) + switch self.reference { + case .array(let array, let index): + array.insert(value, at: index) - case .dictionary(let dictionary, let key): - dictionary[NSString(string: key)] = value - } + case .dictionary(let dictionary, let key): + dictionary[NSString(string: key)] = value } + } } //===----------------------------------------------------------------------===// @@ -607,1190 +689,1551 @@ fileprivate class _PlistReferencingEncoder : _PlistEncoder { /// `PropertyListDecoder` facilitates the decoding of property list values into semantic `Decodable` types. open class PropertyListDecoder { - // MARK: Options - - /// Contextual user-provided information for use during decoding. - open var userInfo: [CodingUserInfoKey : Any] = [:] - - /// Options set on the top-level encoder to pass down the decoding hierarchy. - fileprivate struct _Options { - let userInfo: [CodingUserInfoKey : Any] - } - - /// The options set on the top-level decoder. - fileprivate var options: _Options { - return _Options(userInfo: userInfo) - } - - // MARK: - Constructing a Property List Decoder - - /// Initializes `self` with default strategies. - public init() {} - - // MARK: - Decoding Values - - /// Decodes a top-level value of the given type from the given property list representation. - /// - /// - parameter type: The type of the value to decode. - /// - parameter data: The data to decode from. - /// - returns: A value of the requested type. - /// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not a valid property list. - /// - throws: An error if any value throws an error during decoding. - open func decode(_ type: T.Type, from data: Data) throws -> T { - var format: PropertyListSerialization.PropertyListFormat = .binary - return try decode(type, from: data, format: &format) - } - - /// Decodes a top-level value of the given type from the given property list representation. - /// - /// - parameter type: The type of the value to decode. - /// - parameter data: The data to decode from. - /// - parameter format: The parsed property list format. - /// - returns: A value of the requested type along with the detected format of the property list. - /// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not a valid property list. - /// - throws: An error if any value throws an error during decoding. - open func decode(_ type: T.Type, from data: Data, format: inout PropertyListSerialization.PropertyListFormat) throws -> T { - let topLevel: Any - do { - topLevel = try PropertyListSerialization.propertyList(from: data, options: [], format: &format) - } catch { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "The given data was not a valid property list.", underlyingError: error)) - } - - return try decode(type, fromTopLevel: topLevel) - } - - /// Decodes a top-level value of the given type from the given property list container (top-level array or dictionary). - /// - /// - parameter type: The type of the value to decode. - /// - parameter container: The top-level plist container. - /// - returns: A value of the requested type. - /// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not a valid property list. - /// - throws: An error if any value throws an error during decoding. - internal func decode(_ type: T.Type, fromTopLevel container: Any) throws -> T { - let decoder = _PlistDecoder(referencing: container, options: self.options) - guard let value = try decoder.unbox(container, as: type) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: [], debugDescription: "The given data did not contain a top-level value.")) - } - - return value - } + // MARK: Options + + /// Contextual user-provided information for use during decoding. + open var userInfo: [CodingUserInfoKey: Any] = [:] + + /// Options set on the top-level encoder to pass down the decoding hierarchy. + fileprivate struct _Options { + let userInfo: [CodingUserInfoKey: Any] + } + + /// The options set on the top-level decoder. + fileprivate var options: _Options { + return _Options(userInfo: userInfo) + } + + // MARK: - Constructing a Property List Decoder + + /// Initializes `self` with default strategies. + public init() {} + + // MARK: - Decoding Values + + /// Decodes a top-level value of the given type from the given property list representation. + /// + /// - parameter type: The type of the value to decode. + /// - parameter data: The data to decode from. + /// - returns: A value of the requested type. + /// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not a valid property list. + /// - throws: An error if any value throws an error during decoding. + open func decode(_ type: T.Type, from data: Data) throws -> T { + var format: PropertyListSerialization.PropertyListFormat = .binary + return try decode(type, from: data, format: &format) + } + + /// Decodes a top-level value of the given type from the given property list representation. + /// + /// - parameter type: The type of the value to decode. + /// - parameter data: The data to decode from. + /// - parameter format: The parsed property list format. + /// - returns: A value of the requested type along with the detected format of the property list. + /// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not a valid property list. + /// - throws: An error if any value throws an error during decoding. + open func decode( + _ type: T.Type, from data: Data, format: inout PropertyListSerialization.PropertyListFormat + ) throws -> T { + let topLevel: Any + do { + topLevel = try PropertyListSerialization.propertyList( + from: data, options: [], format: &format) + } catch { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: [], debugDescription: "The given data was not a valid property list.", + underlyingError: error)) + } + + return try decode(type, fromTopLevel: topLevel) + } + + /// Decodes a top-level value of the given type from the given property list container (top-level array or dictionary). + /// + /// - parameter type: The type of the value to decode. + /// - parameter container: The top-level plist container. + /// - returns: A value of the requested type. + /// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not a valid property list. + /// - throws: An error if any value throws an error during decoding. + internal func decode(_ type: T.Type, fromTopLevel container: Any) throws -> T { + let decoder = _PlistDecoder(referencing: container, options: self.options) + guard let value = try decoder.unbox(container, as: type) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: [], debugDescription: "The given data did not contain a top-level value.")) + } + + return value + } } // MARK: - _PlistDecoder -fileprivate class _PlistDecoder : Decoder { - // MARK: Properties +private class _PlistDecoder: Decoder { + // MARK: Properties - /// The decoder's storage. - fileprivate var storage: _PlistDecodingStorage + /// The decoder's storage. + fileprivate var storage: _PlistDecodingStorage - /// Options set on the top-level decoder. - fileprivate let options: PropertyListDecoder._Options + /// Options set on the top-level decoder. + fileprivate let options: PropertyListDecoder._Options - /// The path to the current point in encoding. - fileprivate(set) public var codingPath: [CodingKey] + /// The path to the current point in encoding. + fileprivate(set) public var codingPath: [CodingKey] - /// Contextual user-provided information for use during encoding. - public var userInfo: [CodingUserInfoKey : Any] { - return self.options.userInfo - } + /// Contextual user-provided information for use during encoding. + public var userInfo: [CodingUserInfoKey: Any] { + return self.options.userInfo + } - // MARK: - Initialization + // MARK: - Initialization - /// Initializes `self` with the given top-level container and options. - fileprivate init(referencing container: Any, at codingPath: [CodingKey] = [], options: PropertyListDecoder._Options) { - self.storage = _PlistDecodingStorage() - self.storage.push(container: container) - self.codingPath = codingPath - self.options = options - } + /// Initializes `self` with the given top-level container and options. + fileprivate init( + referencing container: Any, at codingPath: [CodingKey] = [], + options: PropertyListDecoder._Options + ) { + self.storage = _PlistDecodingStorage() + self.storage.push(container: container) + self.codingPath = codingPath + self.options = options + } - // MARK: - Decoder Methods + // MARK: - Decoder Methods - public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { - guard !(self.storage.topContainer is NSNull) else { - throw DecodingError.valueNotFound(KeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get keyed decoding container -- found null value instead.")) - } - - guard let topContainer = self.storage.topContainer as? [String : Any] else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: [String : Any].self, reality: self.storage.topContainer) - } - - let container = _PlistKeyedDecodingContainer(referencing: self, wrapping: topContainer) - return KeyedDecodingContainer(container) + public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { + guard !(self.storage.topContainer is NSNull) else { + throw DecodingError.valueNotFound( + KeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Cannot get keyed decoding container -- found null value instead.")) } - public func unkeyedContainer() throws -> UnkeyedDecodingContainer { - guard !(self.storage.topContainer is NSNull) else { - throw DecodingError.valueNotFound(UnkeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get unkeyed decoding container -- found null value instead.")) - } + guard let topContainer = self.storage.topContainer as? [String: Any] else { + throw DecodingError._typeMismatch( + at: self.codingPath, expectation: [String: Any].self, reality: self.storage.topContainer) + } - guard let topContainer = self.storage.topContainer as? [Any] else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: [Any].self, reality: self.storage.topContainer) - } + let container = _PlistKeyedDecodingContainer(referencing: self, wrapping: topContainer) + return KeyedDecodingContainer(container) + } - return _PlistUnkeyedDecodingContainer(referencing: self, wrapping: topContainer) + public func unkeyedContainer() throws -> UnkeyedDecodingContainer { + guard !(self.storage.topContainer is NSNull) else { + throw DecodingError.valueNotFound( + UnkeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Cannot get unkeyed decoding container -- found null value instead.")) } - public func singleValueContainer() throws -> SingleValueDecodingContainer { - return self + guard let topContainer = self.storage.topContainer as? [Any] else { + throw DecodingError._typeMismatch( + at: self.codingPath, expectation: [Any].self, reality: self.storage.topContainer) } + + return _PlistUnkeyedDecodingContainer(referencing: self, wrapping: topContainer) + } + + public func singleValueContainer() throws -> SingleValueDecodingContainer { + return self + } } // MARK: - Decoding Storage -fileprivate struct _PlistDecodingStorage { - // MARK: Properties +private struct _PlistDecodingStorage { + // MARK: Properties - /// The container stack. - /// Elements may be any one of the plist types (NSNumber, Date, String, Array, [String : Any]). - private(set) fileprivate var containers: [Any] = [] + /// The container stack. + /// Elements may be any one of the plist types (NSNumber, Date, String, Array, [String : Any]). + private(set) fileprivate var containers: [Any] = [] - // MARK: - Initialization + // MARK: - Initialization - /// Initializes `self` with no containers. - fileprivate init() {} + /// Initializes `self` with no containers. + fileprivate init() {} - // MARK: - Modifying the Stack + // MARK: - Modifying the Stack - fileprivate var count: Int { - return self.containers.count - } + fileprivate var count: Int { + return self.containers.count + } - fileprivate var topContainer: Any { - precondition(!self.containers.isEmpty, "Empty container stack.") - return self.containers.last! - } + fileprivate var topContainer: Any { + precondition(!self.containers.isEmpty, "Empty container stack.") + return self.containers.last! + } - fileprivate mutating func push(container: __owned Any) { - self.containers.append(container) - } + fileprivate mutating func push(container: __owned Any) { + self.containers.append(container) + } - fileprivate mutating func popContainer() { - precondition(!self.containers.isEmpty, "Empty container stack.") - self.containers.removeLast() - } + fileprivate mutating func popContainer() { + precondition(!self.containers.isEmpty, "Empty container stack.") + self.containers.removeLast() + } } // MARK: Decoding Containers -fileprivate struct _PlistKeyedDecodingContainer : KeyedDecodingContainerProtocol { - typealias Key = K +private struct _PlistKeyedDecodingContainer: KeyedDecodingContainerProtocol { + typealias Key = K - // MARK: Properties + // MARK: Properties - /// A reference to the decoder we're reading from. - private let decoder: _PlistDecoder + /// A reference to the decoder we're reading from. + private let decoder: _PlistDecoder - /// A reference to the container we're reading from. - private let container: [String : Any] + /// A reference to the container we're reading from. + private let container: [String: Any] - /// The path of coding keys taken to get to this point in decoding. - private(set) public var codingPath: [CodingKey] + /// The path of coding keys taken to get to this point in decoding. + private(set) public var codingPath: [CodingKey] - // MARK: - Initialization + // MARK: - Initialization - /// Initializes `self` by referencing the given decoder and container. - fileprivate init(referencing decoder: _PlistDecoder, wrapping container: [String : Any]) { - self.decoder = decoder - self.container = container - self.codingPath = decoder.codingPath - } + /// Initializes `self` by referencing the given decoder and container. + fileprivate init(referencing decoder: _PlistDecoder, wrapping container: [String: Any]) { + self.decoder = decoder + self.container = container + self.codingPath = decoder.codingPath + } - // MARK: - KeyedDecodingContainerProtocol Methods + // MARK: - KeyedDecodingContainerProtocol Methods - public var allKeys: [Key] { - return self.container.keys.compactMap { Key(stringValue: $0) } - } + public var allKeys: [Key] { + return self.container.keys.compactMap { Key(stringValue: $0) } + } - public func contains(_ key: Key) -> Bool { - return self.container[key.stringValue] != nil - } - - public func decodeNil(forKey key: Key) throws -> Bool { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } + public func contains(_ key: Key) -> Bool { + return self.container[key.stringValue] != nil + } - guard let value = entry as? String else { - return false - } - - return value == _plistNull + public func decodeNil(forKey key: Key) throws -> Bool { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } - - guard let value = try self.decoder.unbox(entry, as: Bool.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + guard let value = entry as? String else { + return false } - public func decode(_ type: Int.Type, forKey key: Key) throws -> Int { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + return value == _plistNull + } - guard let value = try self.decoder.unbox(entry, as: Int.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + public func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - guard let value = try self.decoder.unbox(entry, as: Int8.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + guard let value = try self.decoder.unbox(entry, as: Bool.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + return value + } - guard let value = try self.decoder.unbox(entry, as: Int16.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + public func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - guard let value = try self.decoder.unbox(entry, as: Int32.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + guard let value = try self.decoder.unbox(entry, as: Int.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + return value + } - guard let value = try self.decoder.unbox(entry, as: Int64.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + public func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - guard let value = try self.decoder.unbox(entry, as: UInt.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + guard let value = try self.decoder.unbox(entry, as: Int8.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + return value + } - guard let value = try self.decoder.unbox(entry, as: UInt8.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + public func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } - - guard let value = try self.decoder.unbox(entry, as: UInt16.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - return value + guard let value = try self.decoder.unbox(entry, as: Int16.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } + return value + } - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } - - guard let value = try self.decoder.unbox(entry, as: UInt32.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + public func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } - - guard let value = try self.decoder.unbox(entry, as: UInt64.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - return value + guard let value = try self.decoder.unbox(entry, as: Int32.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public func decode(_ type: Float.Type, forKey key: Key) throws -> Float { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } + return value + } - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } - guard let value = try self.decoder.unbox(entry, as: Float.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + public func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: Double.Type, forKey key: Key) throws -> Double { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - guard let value = try self.decoder.unbox(entry, as: Double.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + guard let value = try self.decoder.unbox(entry, as: Int64.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public func decode(_ type: String.Type, forKey key: Key) throws -> String { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + return value + } - guard let value = try self.decoder.unbox(entry, as: String.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + public func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func decode(_ type: T.Type, forKey key: Key) throws -> T { - guard let entry = self.container[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) - } - - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - guard let value = try self.decoder.unbox(entry, as: type) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "Expected \(type) value but found null instead.")) - } - - return value + guard let value = try self.decoder.unbox(entry, as: UInt.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer { - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } - - guard let value = self.container[key.stringValue] else { - throw DecodingError.valueNotFound(KeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get nested keyed container -- no value found for key \"\(key.stringValue)\"")) - } + return value + } - guard let dictionary = value as? [String : Any] else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: [String : Any].self, reality: value) - } - - let container = _PlistKeyedDecodingContainer(referencing: self.decoder, wrapping: dictionary) - return KeyedDecodingContainer(container) + public func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } - - guard let value = self.container[key.stringValue] else { - throw DecodingError.valueNotFound(UnkeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get nested unkeyed container -- no value found for key \"\(key.stringValue)\"")) - } - - guard let array = value as? [Any] else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: [Any].self, reality: value) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - return _PlistUnkeyedDecodingContainer(referencing: self.decoder, wrapping: array) + guard let value = try self.decoder.unbox(entry, as: UInt8.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - private func _superDecoder(forKey key: __owned CodingKey) throws -> Decoder { - self.decoder.codingPath.append(key) - defer { self.decoder.codingPath.removeLast() } + return value + } - let value: Any = self.container[key.stringValue] ?? NSNull() - return _PlistDecoder(referencing: value, at: self.decoder.codingPath, options: self.decoder.options) + public func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public func superDecoder() throws -> Decoder { - return try _superDecoder(forKey: _PlistKey.super) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - public func superDecoder(forKey key: Key) throws -> Decoder { - return try _superDecoder(forKey: key) + guard let value = try self.decoder.unbox(entry, as: UInt16.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } -} - -fileprivate struct _PlistUnkeyedDecodingContainer : UnkeyedDecodingContainer { - // MARK: Properties - - /// A reference to the decoder we're reading from. - private let decoder: _PlistDecoder - - /// A reference to the container we're reading from. - private let container: [Any] - - /// The path of coding keys taken to get to this point in decoding. - private(set) public var codingPath: [CodingKey] - - /// The index of the element we're about to decode. - private(set) public var currentIndex: Int - // MARK: - Initialization + return value + } - /// Initializes `self` by referencing the given decoder and container. - fileprivate init(referencing decoder: _PlistDecoder, wrapping container: [Any]) { - self.decoder = decoder - self.container = container - self.codingPath = decoder.codingPath - self.currentIndex = 0 + public func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - // MARK: - UnkeyedDecodingContainer Methods + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - public var count: Int? { - return self.container.count + guard let value = try self.decoder.unbox(entry, as: UInt32.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public var isAtEnd: Bool { - return self.currentIndex >= self.count! - } - - public mutating func decodeNil() throws -> Bool { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(Any?.self, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } + return value + } - if self.container[self.currentIndex] is NSNull { - self.currentIndex += 1 - return true - } else { - return false - } + public func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public mutating func decode(_ type: Bool.Type) throws -> Bool { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Bool.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - self.currentIndex += 1 - return decoded + guard let value = try self.decoder.unbox(entry, as: UInt64.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public mutating func decode(_ type: Int.Type) throws -> Int { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } + return value + } - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } - - self.currentIndex += 1 - return decoded + public func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public mutating func decode(_ type: Int8.Type) throws -> Int8 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int8.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } - - self.currentIndex += 1 - return decoded + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } + guard let value = try self.decoder.unbox(entry, as: Float.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public mutating func decode(_ type: Int16.Type) throws -> Int16 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int16.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + return value + } - self.currentIndex += 1 - return decoded + public func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public mutating func decode(_ type: Int32.Type) throws -> Int32 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int32.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } - - self.currentIndex += 1 - return decoded + guard let value = try self.decoder.unbox(entry, as: Double.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public mutating func decode(_ type: Int64.Type) throws -> Int64 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int64.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + return value + } - self.currentIndex += 1 - return decoded + public func decode(_ type: String.Type, forKey key: Key) throws -> String { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public mutating func decode(_ type: UInt.Type) throws -> UInt { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } - - self.currentIndex += 1 - return decoded + guard let value = try self.decoder.unbox(entry, as: String.self) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public mutating func decode(_ type: UInt8.Type) throws -> UInt8 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt8.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + return value + } - self.currentIndex += 1 - return decoded + public func decode(_ type: T.Type, forKey key: Key) throws -> T { + guard let entry = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "No value associated with key \(key) (\"\(key.stringValue)\").")) } - public mutating func decode(_ type: UInt16.Type) throws -> UInt16 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt16.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } - - self.currentIndex += 1 - return decoded + guard let value = try self.decoder.unbox(entry, as: type) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead.")) } - public mutating func decode(_ type: UInt32.Type) throws -> UInt32 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } + return value + } - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt32.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + public func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws + -> KeyedDecodingContainer + { + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - self.currentIndex += 1 - return decoded + guard let value = self.container[key.stringValue] else { + throw DecodingError.valueNotFound( + KeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get nested keyed container -- no value found for key \"\(key.stringValue)\"")) } - public mutating func decode(_ type: UInt64.Type) throws -> UInt64 { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt64.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } - - self.currentIndex += 1 - return decoded + guard let dictionary = value as? [String: Any] else { + throw DecodingError._typeMismatch( + at: self.codingPath, expectation: [String: Any].self, reality: value) } - public mutating func decode(_ type: Float.Type) throws -> Float { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } + let container = _PlistKeyedDecodingContainer( + referencing: self.decoder, wrapping: dictionary) + return KeyedDecodingContainer(container) + } - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Float.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + public func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - self.currentIndex += 1 - return decoded + guard let value = self.container[key.stringValue] else { + throw DecodingError.valueNotFound( + UnkeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get nested unkeyed container -- no value found for key \"\(key.stringValue)\"")) } - public mutating func decode(_ type: Double.Type) throws -> Double { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } - - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } - - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Double.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } - - self.currentIndex += 1 - return decoded + guard let array = value as? [Any] else { + throw DecodingError._typeMismatch( + at: self.codingPath, expectation: [Any].self, reality: value) } - public mutating func decode(_ type: String.Type) throws -> String { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } + return _PlistUnkeyedDecodingContainer(referencing: self.decoder, wrapping: array) + } - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } + private func _superDecoder(forKey key: __owned CodingKey) throws -> Decoder { + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: String.self) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + let value: Any = self.container[key.stringValue] ?? NSNull() + return _PlistDecoder( + referencing: value, at: self.decoder.codingPath, options: self.decoder.options) + } - self.currentIndex += 1 - return decoded - } + public func superDecoder() throws -> Decoder { + return try _superDecoder(forKey: _PlistKey.super) + } - public mutating func decode(_ type: T.Type) throws -> T { - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Unkeyed container is at end.")) - } + public func superDecoder(forKey key: Key) throws -> Decoder { + return try _superDecoder(forKey: key) + } +} - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } +private struct _PlistUnkeyedDecodingContainer: UnkeyedDecodingContainer { + // MARK: Properties - guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: type) else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], debugDescription: "Expected \(type) but found null instead.")) - } + /// A reference to the decoder we're reading from. + private let decoder: _PlistDecoder - self.currentIndex += 1 - return decoded - } + /// A reference to the container we're reading from. + private let container: [Any] - public mutating func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer { - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } + /// The path of coding keys taken to get to this point in decoding. + private(set) public var codingPath: [CodingKey] - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(KeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get nested keyed container -- unkeyed container is at end.")) - } + /// The index of the element we're about to decode. + private(set) public var currentIndex: Int - let value = self.container[self.currentIndex] - guard !(value is NSNull) else { - throw DecodingError.valueNotFound(KeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get keyed decoding container -- found null value instead.")) - } + // MARK: - Initialization - guard let dictionary = value as? [String : Any] else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: [String : Any].self, reality: value) - } + /// Initializes `self` by referencing the given decoder and container. + fileprivate init(referencing decoder: _PlistDecoder, wrapping container: [Any]) { + self.decoder = decoder + self.container = container + self.codingPath = decoder.codingPath + self.currentIndex = 0 + } - self.currentIndex += 1 - let container = _PlistKeyedDecodingContainer(referencing: self.decoder, wrapping: dictionary) - return KeyedDecodingContainer(container) - } + // MARK: - UnkeyedDecodingContainer Methods - public mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } + public var count: Int? { + return self.container.count + } - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(UnkeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get nested unkeyed container -- unkeyed container is at end.")) - } + public var isAtEnd: Bool { + return self.currentIndex >= self.count! + } - let value = self.container[self.currentIndex] - guard !(value is NSNull) else { - throw DecodingError.valueNotFound(UnkeyedDecodingContainer.self, - DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get keyed decoding container -- found null value instead.")) - } - - guard let array = value as? [Any] else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: [Any].self, reality: value) - } + public mutating func decodeNil() throws -> Bool { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + Any?.self, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } - self.currentIndex += 1 - return _PlistUnkeyedDecodingContainer(referencing: self.decoder, wrapping: array) + if self.container[self.currentIndex] is NSNull { + self.currentIndex += 1 + return true + } else { + return false } + } - public mutating func superDecoder() throws -> Decoder { - self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) - defer { self.decoder.codingPath.removeLast() } + public mutating func decode(_ type: Bool.Type) throws -> Bool { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } - guard !self.isAtEnd else { - throw DecodingError.valueNotFound(Decoder.self, DecodingError.Context(codingPath: self.codingPath, - debugDescription: "Cannot get superDecoder() -- unkeyed container is at end.")) - } + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } - let value = self.container[self.currentIndex] - self.currentIndex += 1 - return _PlistDecoder(referencing: value, at: self.decoder.codingPath, options: self.decoder.options) + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Bool.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) } -} -extension _PlistDecoder : SingleValueDecodingContainer { - // MARK: SingleValueDecodingContainer Methods + self.currentIndex += 1 + return decoded + } - private func expectNonNull(_ type: T.Type) throws { - guard !self.decodeNil() else { - throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: self.codingPath, debugDescription: "Expected \(type) but found null value instead.")) - } + public mutating func decode(_ type: Int.Type) throws -> Int { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) } - public func decodeNil() -> Bool { - guard let string = self.storage.topContainer as? String else { - return false - } + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } - return string == _plistNull + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) } - public func decode(_ type: Bool.Type) throws -> Bool { - try expectNonNull(Bool.self) - return try self.unbox(self.storage.topContainer, as: Bool.self)! - } + self.currentIndex += 1 + return decoded + } - public func decode(_ type: Int.Type) throws -> Int { - try expectNonNull(Int.self) - return try self.unbox(self.storage.topContainer, as: Int.self)! + public mutating func decode(_ type: Int8.Type) throws -> Int8 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int8.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: Int16.Type) throws -> Int16 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int16.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: Int32.Type) throws -> Int32 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int32.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: Int64.Type) throws -> Int64 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Int64.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: UInt.Type) throws -> UInt { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: UInt8.Type) throws -> UInt8 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt8.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: UInt16.Type) throws -> UInt16 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt16.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: UInt32.Type) throws -> UInt32 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt32.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: UInt64.Type) throws -> UInt64 { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: UInt64.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) } - - public func decode(_ type: Int8.Type) throws -> Int8 { - try expectNonNull(Int8.self) - return try self.unbox(self.storage.topContainer, as: Int8.self)! + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: Float.Type) throws -> Float { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) + } + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Float.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) + } + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: Double.Type) throws -> Double { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) } - - public func decode(_ type: Int16.Type) throws -> Int16 { - try expectNonNull(Int16.self) - return try self.unbox(self.storage.topContainer, as: Int16.self)! + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: Double.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) } - - public func decode(_ type: Int32.Type) throws -> Int32 { - try expectNonNull(Int32.self) - return try self.unbox(self.storage.topContainer, as: Int32.self)! + + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: String.Type) throws -> String { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) } - public func decode(_ type: Int64.Type) throws -> Int64 { - try expectNonNull(Int64.self) - return try self.unbox(self.storage.topContainer, as: Int64.self)! + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: String.self) + else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) } - public func decode(_ type: UInt.Type) throws -> UInt { - try expectNonNull(UInt.self) - return try self.unbox(self.storage.topContainer, as: UInt.self)! + self.currentIndex += 1 + return decoded + } + + public mutating func decode(_ type: T.Type) throws -> T { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end.")) } - - public func decode(_ type: UInt8.Type) throws -> UInt8 { - try expectNonNull(UInt8.self) - return try self.unbox(self.storage.topContainer, as: UInt8.self)! + + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard let decoded = try self.decoder.unbox(self.container[self.currentIndex], as: type) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + [_PlistKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead.")) } - public func decode(_ type: UInt16.Type) throws -> UInt16 { - try expectNonNull(UInt16.self) - return try self.unbox(self.storage.topContainer, as: UInt16.self)! - } + self.currentIndex += 1 + return decoded + } + + public mutating func nestedContainer(keyedBy type: NestedKey.Type) throws + -> KeyedDecodingContainer + { + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + KeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Cannot get nested keyed container -- unkeyed container is at end.")) + } + + let value = self.container[self.currentIndex] + guard !(value is NSNull) else { + throw DecodingError.valueNotFound( + KeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Cannot get keyed decoding container -- found null value instead.")) + } + + guard let dictionary = value as? [String: Any] else { + throw DecodingError._typeMismatch( + at: self.codingPath, expectation: [String: Any].self, reality: value) + } + + self.currentIndex += 1 + let container = _PlistKeyedDecodingContainer( + referencing: self.decoder, wrapping: dictionary) + return KeyedDecodingContainer(container) + } - public func decode(_ type: UInt32.Type) throws -> UInt32 { - try expectNonNull(UInt32.self) - return try self.unbox(self.storage.topContainer, as: UInt32.self)! + public mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + UnkeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Cannot get nested unkeyed container -- unkeyed container is at end.")) } - public func decode(_ type: UInt64.Type) throws -> UInt64 { - try expectNonNull(UInt64.self) - return try self.unbox(self.storage.topContainer, as: UInt64.self)! - } + let value = self.container[self.currentIndex] + guard !(value is NSNull) else { + throw DecodingError.valueNotFound( + UnkeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Cannot get keyed decoding container -- found null value instead.")) + } - public func decode(_ type: Float.Type) throws -> Float { - try expectNonNull(Float.self) - return try self.unbox(self.storage.topContainer, as: Float.self)! + guard let array = value as? [Any] else { + throw DecodingError._typeMismatch( + at: self.codingPath, expectation: [Any].self, reality: value) } - public func decode(_ type: Double.Type) throws -> Double { - try expectNonNull(Double.self) - return try self.unbox(self.storage.topContainer, as: Double.self)! - } + self.currentIndex += 1 + return _PlistUnkeyedDecodingContainer(referencing: self.decoder, wrapping: array) + } + + public mutating func superDecoder() throws -> Decoder { + self.decoder.codingPath.append(_PlistKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } - public func decode(_ type: String.Type) throws -> String { - try expectNonNull(String.self) - return try self.unbox(self.storage.topContainer, as: String.self)! + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + Decoder.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Cannot get superDecoder() -- unkeyed container is at end.")) } - public func decode(_ type: T.Type) throws -> T { - try expectNonNull(type) - return try self.unbox(self.storage.topContainer, as: type)! - } + let value = self.container[self.currentIndex] + self.currentIndex += 1 + return _PlistDecoder( + referencing: value, at: self.decoder.codingPath, options: self.decoder.options) + } +} + +extension _PlistDecoder: SingleValueDecodingContainer { + // MARK: SingleValueDecodingContainer Methods + + private func expectNonNull(_ type: T.Type) throws { + guard !self.decodeNil() else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Expected \(type) but found null value instead.")) + } + } + + public func decodeNil() -> Bool { + guard let string = self.storage.topContainer as? String else { + return false + } + + return string == _plistNull + } + + public func decode(_ type: Bool.Type) throws -> Bool { + try expectNonNull(Bool.self) + return try self.unbox(self.storage.topContainer, as: Bool.self)! + } + + public func decode(_ type: Int.Type) throws -> Int { + try expectNonNull(Int.self) + return try self.unbox(self.storage.topContainer, as: Int.self)! + } + + public func decode(_ type: Int8.Type) throws -> Int8 { + try expectNonNull(Int8.self) + return try self.unbox(self.storage.topContainer, as: Int8.self)! + } + + public func decode(_ type: Int16.Type) throws -> Int16 { + try expectNonNull(Int16.self) + return try self.unbox(self.storage.topContainer, as: Int16.self)! + } + + public func decode(_ type: Int32.Type) throws -> Int32 { + try expectNonNull(Int32.self) + return try self.unbox(self.storage.topContainer, as: Int32.self)! + } + + public func decode(_ type: Int64.Type) throws -> Int64 { + try expectNonNull(Int64.self) + return try self.unbox(self.storage.topContainer, as: Int64.self)! + } + + public func decode(_ type: UInt.Type) throws -> UInt { + try expectNonNull(UInt.self) + return try self.unbox(self.storage.topContainer, as: UInt.self)! + } + + public func decode(_ type: UInt8.Type) throws -> UInt8 { + try expectNonNull(UInt8.self) + return try self.unbox(self.storage.topContainer, as: UInt8.self)! + } + + public func decode(_ type: UInt16.Type) throws -> UInt16 { + try expectNonNull(UInt16.self) + return try self.unbox(self.storage.topContainer, as: UInt16.self)! + } + + public func decode(_ type: UInt32.Type) throws -> UInt32 { + try expectNonNull(UInt32.self) + return try self.unbox(self.storage.topContainer, as: UInt32.self)! + } + + public func decode(_ type: UInt64.Type) throws -> UInt64 { + try expectNonNull(UInt64.self) + return try self.unbox(self.storage.topContainer, as: UInt64.self)! + } + + public func decode(_ type: Float.Type) throws -> Float { + try expectNonNull(Float.self) + return try self.unbox(self.storage.topContainer, as: Float.self)! + } + + public func decode(_ type: Double.Type) throws -> Double { + try expectNonNull(Double.self) + return try self.unbox(self.storage.topContainer, as: Double.self)! + } + + public func decode(_ type: String.Type) throws -> String { + try expectNonNull(String.self) + return try self.unbox(self.storage.topContainer, as: String.self)! + } + + public func decode(_ type: T.Type) throws -> T { + try expectNonNull(type) + return try self.unbox(self.storage.topContainer, as: type)! + } } // MARK: - Concrete Value Representations extension _PlistDecoder { - /// Returns the given value unboxed from a container. - fileprivate func unbox(_ value: Any, as type: Bool.Type) throws -> Bool? { - if let string = value as? String, string == _plistNull { return nil } - - if let number = value as? NSNumber { - // TODO: Add a flag to coerce non-boolean numbers into Bools? - if number === kCFBooleanTrue as NSNumber { - return true - } else if number === kCFBooleanFalse as NSNumber { - return false - } - - /* FIXME: If swift-corelibs-foundation doesn't change to use NSNumber, this code path will need to be included and tested: + /// Returns the given value unboxed from a container. + fileprivate func unbox(_ value: Any, as type: Bool.Type) throws -> Bool? { + if let string = value as? String, string == _plistNull { return nil } + + if let number = value as? NSNumber { + // TODO: Add a flag to coerce non-boolean numbers into Bools? + if number === kCFBooleanTrue as NSNumber { + return true + } else if number === kCFBooleanFalse as NSNumber { + return false + } + + /* FIXME: If swift-corelibs-foundation doesn't change to use NSNumber, this code path will need to be included and tested: } else if let bool = value as? Bool { return bool */ - } - - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) } - fileprivate func unbox(_ value: Any, as type: Int.Type) throws -> Int? { - if let string = value as? String, string == _plistNull { return nil } + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Int.Type) throws -> Int? { + if let string = value as? String, string == _plistNull { return nil } - let int = number.intValue - guard NSNumber(value: int) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return int + let int = number.intValue + guard NSNumber(value: int) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: Int8.Type) throws -> Int8? { - if let string = value as? String, string == _plistNull { return nil } + return int + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Int8.Type) throws -> Int8? { + if let string = value as? String, string == _plistNull { return nil } - let int8 = number.int8Value - guard NSNumber(value: int8) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return int8 + let int8 = number.int8Value + guard NSNumber(value: int8) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: Int16.Type) throws -> Int16? { - if let string = value as? String, string == _plistNull { return nil } + return int8 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Int16.Type) throws -> Int16? { + if let string = value as? String, string == _plistNull { return nil } - let int16 = number.int16Value - guard NSNumber(value: int16) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return int16 + let int16 = number.int16Value + guard NSNumber(value: int16) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: Int32.Type) throws -> Int32? { - if let string = value as? String, string == _plistNull { return nil } + return int16 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Int32.Type) throws -> Int32? { + if let string = value as? String, string == _plistNull { return nil } - let int32 = number.int32Value - guard NSNumber(value: int32) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return int32 + let int32 = number.int32Value + guard NSNumber(value: int32) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: Int64.Type) throws -> Int64? { - if let string = value as? String, string == _plistNull { return nil } + return int32 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Int64.Type) throws -> Int64? { + if let string = value as? String, string == _plistNull { return nil } - let int64 = number.int64Value - guard NSNumber(value: int64) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return int64 + let int64 = number.int64Value + guard NSNumber(value: int64) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: UInt.Type) throws -> UInt? { - if let string = value as? String, string == _plistNull { return nil } + return int64 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: UInt.Type) throws -> UInt? { + if let string = value as? String, string == _plistNull { return nil } - let uint = number.uintValue - guard NSNumber(value: uint) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return uint + let uint = number.uintValue + guard NSNumber(value: uint) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: UInt8.Type) throws -> UInt8? { - if let string = value as? String, string == _plistNull { return nil } + return uint + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: UInt8.Type) throws -> UInt8? { + if let string = value as? String, string == _plistNull { return nil } - let uint8 = number.uint8Value - guard NSNumber(value: uint8) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return uint8 + let uint8 = number.uint8Value + guard NSNumber(value: uint8) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: UInt16.Type) throws -> UInt16? { - if let string = value as? String, string == _plistNull { return nil } + return uint8 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: UInt16.Type) throws -> UInt16? { + if let string = value as? String, string == _plistNull { return nil } - let uint16 = number.uint16Value - guard NSNumber(value: uint16) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return uint16 + let uint16 = number.uint16Value + guard NSNumber(value: uint16) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: UInt32.Type) throws -> UInt32? { - if let string = value as? String, string == _plistNull { return nil } + return uint16 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: UInt32.Type) throws -> UInt32? { + if let string = value as? String, string == _plistNull { return nil } - let uint32 = number.uint32Value - guard NSNumber(value: uint32) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return uint32 + let uint32 = number.uint32Value + guard NSNumber(value: uint32) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: UInt64.Type) throws -> UInt64? { - if let string = value as? String, string == _plistNull { return nil } + return uint32 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: UInt64.Type) throws -> UInt64? { + if let string = value as? String, string == _plistNull { return nil } - let uint64 = number.uint64Value - guard NSNumber(value: uint64) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return uint64 + let uint64 = number.uint64Value + guard NSNumber(value: uint64) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: Float.Type) throws -> Float? { - if let string = value as? String, string == _plistNull { return nil } + return uint64 + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Float.Type) throws -> Float? { + if let string = value as? String, string == _plistNull { return nil } - let float = number.floatValue - guard NSNumber(value: float) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return float + let float = number.floatValue + guard NSNumber(value: float) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: Double.Type) throws -> Double? { - if let string = value as? String, string == _plistNull { return nil } + return float + } - guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Double.Type) throws -> Double? { + if let string = value as? String, string == _plistNull { return nil } - let double = number.doubleValue - guard NSNumber(value: double) == number else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) - } + guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse + else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) + } - return double + let double = number.doubleValue + guard NSNumber(value: double) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Parsed property list number <\(number)> does not fit in \(type).")) } - fileprivate func unbox(_ value: Any, as type: String.Type) throws -> String? { - guard let string = value as? String else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + return double + } - return string == _plistNull ? nil : string + fileprivate func unbox(_ value: Any, as type: String.Type) throws -> String? { + guard let string = value as? String else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) } - fileprivate func unbox(_ value: Any, as type: Date.Type) throws -> Date? { - if let string = value as? String, string == _plistNull { return nil } + return string == _plistNull ? nil : string + } - guard let date = value as? Date else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Date.Type) throws -> Date? { + if let string = value as? String, string == _plistNull { return nil } - return date + guard let date = value as? Date else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) } - fileprivate func unbox(_ value: Any, as type: Data.Type) throws -> Data? { - if let string = value as? String, string == _plistNull { return nil } + return date + } - guard let data = value as? Data else { - throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) - } + fileprivate func unbox(_ value: Any, as type: Data.Type) throws -> Data? { + if let string = value as? String, string == _plistNull { return nil } - return data + guard let data = value as? Data else { + throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value) } - fileprivate func unbox(_ value: Any, as type: T.Type) throws -> T? { - if type == Date.self || type == NSDate.self { - return try self.unbox(value, as: Date.self) as? T - } else if type == Data.self || type == NSData.self { - return try self.unbox(value, as: Data.self) as? T - } else { - self.storage.push(container: value) - defer { self.storage.popContainer() } - return try type.init(from: self) - } + return data + } + + fileprivate func unbox(_ value: Any, as type: T.Type) throws -> T? { + if type == Date.self || type == NSDate.self { + return try self.unbox(value, as: Date.self) as? T + } else if type == Data.self || type == NSData.self { + return try self.unbox(value, as: Data.self) as? T + } else { + self.storage.push(container: value) + defer { self.storage.popContainer() } + return try type.init(from: self) } + } } //===----------------------------------------------------------------------===// @@ -1798,31 +2241,31 @@ extension _PlistDecoder { //===----------------------------------------------------------------------===// // Since plists do not support null values by default, we will encode them as "$null". -fileprivate let _plistNull = "$null" -fileprivate let _plistNullNSString = NSString(string: _plistNull) +private let _plistNull = "$null" +private let _plistNullNSString = NSString(string: _plistNull) //===----------------------------------------------------------------------===// // Shared Key Types //===----------------------------------------------------------------------===// -fileprivate struct _PlistKey : CodingKey { - public var stringValue: String - public var intValue: Int? +private struct _PlistKey: CodingKey { + public var stringValue: String + public var intValue: Int? - public init?(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } + public init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } - public init?(intValue: Int) { - self.stringValue = "\(intValue)" - self.intValue = intValue - } + public init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } - fileprivate init(index: Int) { - self.stringValue = "Index \(index)" - self.intValue = index - } + fileprivate init(index: Int) { + self.stringValue = "Index \(index)" + self.intValue = index + } - fileprivate static let `super` = _PlistKey(stringValue: "super")! + fileprivate static let `super` = _PlistKey(stringValue: "super")! } diff --git a/Sources/SnapshotTesting/Common/String+SpecialCharacters.swift b/Sources/SnapshotTesting/Common/String+SpecialCharacters.swift index e084323cc..98956e3f0 100644 --- a/Sources/SnapshotTesting/Common/String+SpecialCharacters.swift +++ b/Sources/SnapshotTesting/Common/String+SpecialCharacters.swift @@ -18,8 +18,8 @@ extension String { /// - Returns: True if the string has any special character literals, false otherwise. func hasEscapedSpecialCharactersLiteral() -> Bool { let multilineLiteralAndNumberSign = ##""" - """# - """## + """# + """## let patterns = [ // Matches \u{n} where n is a 1–8 digit hexadecimal number try? NSRegularExpression(pattern: #"\\u\{[a-fA-f0-9]{1,8}\}"#, options: .init()), @@ -30,13 +30,16 @@ extension String { try? NSRegularExpression(pattern: #"\r"#, options: .ignoreMetacharacters), try? NSRegularExpression(pattern: #"\""#, options: .ignoreMetacharacters), try? NSRegularExpression(pattern: #"\'"#, options: .ignoreMetacharacters), - try? NSRegularExpression(pattern: multilineLiteralAndNumberSign, options: .ignoreMetacharacters), + try? NSRegularExpression( + pattern: multilineLiteralAndNumberSign, options: .ignoreMetacharacters), ] - let matches = patterns.compactMap { $0?.firstMatch(in: self, options: .init(), range: NSRange.init(location: 0, length: self.count)) } + let matches = patterns.compactMap { + $0?.firstMatch( + in: self, options: .init(), range: NSRange.init(location: 0, length: self.count)) + } return matches.count > 0 } - /// This method calculates how many number signs (#) we need to add around a string /// literal to properly escape its content. /// @@ -47,7 +50,8 @@ extension String { func numberOfNumberSignsNeeded() -> Int { let pattern = try! NSRegularExpression(pattern: ##""#{1,}"##, options: .init()) - let matches = pattern.matches(in: self, options: .init(), range: NSRange.init(location: 0, length: self.count)) + let matches = pattern.matches( + in: self, options: .init(), range: NSRange.init(location: 0, length: self.count)) // If we have "## then the length of the match is 3, // which is also the number of "number signs (#)" we need to add diff --git a/Sources/SnapshotTesting/Common/View.swift b/Sources/SnapshotTesting/Common/View.swift index fb6261c94..775072224 100644 --- a/Sources/SnapshotTesting/Common/View.swift +++ b/Sources/SnapshotTesting/Common/View.swift @@ -1,1106 +1,1135 @@ #if os(iOS) || os(macOS) || os(tvOS) -#if os(macOS) -import Cocoa -#endif -import SceneKit -import SpriteKit -#if os(iOS) || os(tvOS) -import UIKit -#endif -#if os(iOS) || os(macOS) -import WebKit -#endif - -#if os(iOS) || os(tvOS) -public struct ViewImageConfig { - public enum Orientation { - case landscape - case portrait - } - public enum TabletOrientation { - public enum PortraitSplits { - case oneThird - case twoThirds - case full - } - public enum LandscapeSplits { - case oneThird - case oneHalf - case twoThirds - case full - } - case landscape(splitView: LandscapeSplits) - case portrait(splitView: PortraitSplits) - } + #if os(macOS) + import Cocoa + #endif + import SceneKit + import SpriteKit + #if os(iOS) || os(tvOS) + import UIKit + #endif + #if os(iOS) || os(macOS) + import WebKit + #endif - public var safeArea: UIEdgeInsets - public var size: CGSize? - public var traits: UITraitCollection - - public init( - safeArea: UIEdgeInsets = .zero, - size: CGSize? = nil, - traits: UITraitCollection = .init() - ) { - self.safeArea = safeArea - self.size = size - self.traits = traits - } + #if os(iOS) || os(tvOS) + public struct ViewImageConfig { + public enum Orientation { + case landscape + case portrait + } + public enum TabletOrientation { + public enum PortraitSplits { + case oneThird + case twoThirds + case full + } + public enum LandscapeSplits { + case oneThird + case oneHalf + case twoThirds + case full + } + case landscape(splitView: LandscapeSplits) + case portrait(splitView: PortraitSplits) + } - #if os(iOS) - public static let iPhoneSe = ViewImageConfig.iPhoneSe(.portrait) - - public static func iPhoneSe(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .zero - size = .init(width: 568, height: 320) - case .portrait: - safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) - size = .init(width: 320, height: 568) - } - return .init(safeArea: safeArea, size: size, traits: .iPhoneSe(orientation)) - } + public var safeArea: UIEdgeInsets + public var size: CGSize? + public var traits: UITraitCollection + + public init( + safeArea: UIEdgeInsets = .zero, + size: CGSize? = nil, + traits: UITraitCollection = .init() + ) { + self.safeArea = safeArea + self.size = size + self.traits = traits + } - public static let iPhone8 = ViewImageConfig.iPhone8(.portrait) - - public static func iPhone8(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .zero - size = .init(width: 667, height: 375) - case .portrait: - safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) - size = .init(width: 375, height: 667) - } - return .init(safeArea: safeArea, size: size, traits: .iPhone8(orientation)) - } + #if os(iOS) + public static let iPhoneSe = ViewImageConfig.iPhoneSe(.portrait) + + public static func iPhoneSe(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .zero + size = .init(width: 568, height: 320) + case .portrait: + safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) + size = .init(width: 320, height: 568) + } + return .init(safeArea: safeArea, size: size, traits: .iPhoneSe(orientation)) + } - public static let iPhone8Plus = ViewImageConfig.iPhone8Plus(.portrait) - - public static func iPhone8Plus(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .zero - size = .init(width: 736, height: 414) - case .portrait: - safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) - size = .init(width: 414, height: 736) - } - return .init(safeArea: safeArea, size: size, traits: .iPhone8Plus(orientation)) - } + public static let iPhone8 = ViewImageConfig.iPhone8(.portrait) + + public static func iPhone8(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .zero + size = .init(width: 667, height: 375) + case .portrait: + safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) + size = .init(width: 375, height: 667) + } + return .init(safeArea: safeArea, size: size, traits: .iPhone8(orientation)) + } - public static let iPhoneX = ViewImageConfig.iPhoneX(.portrait) - - public static func iPhoneX(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) - size = .init(width: 812, height: 375) - case .portrait: - safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) - size = .init(width: 375, height: 812) - } - return .init(safeArea: safeArea, size: size, traits: .iPhoneX(orientation)) - } + public static let iPhone8Plus = ViewImageConfig.iPhone8Plus(.portrait) + + public static func iPhone8Plus(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .zero + size = .init(width: 736, height: 414) + case .portrait: + safeArea = .init(top: 20, left: 0, bottom: 0, right: 0) + size = .init(width: 414, height: 736) + } + return .init(safeArea: safeArea, size: size, traits: .iPhone8Plus(orientation)) + } - public static let iPhoneXsMax = ViewImageConfig.iPhoneXsMax(.portrait) - - public static func iPhoneXsMax(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) - size = .init(width: 896, height: 414) - case .portrait: - safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) - size = .init(width: 414, height: 896) - } - return .init(safeArea: safeArea, size: size, traits: .iPhoneXsMax(orientation)) - } + public static let iPhoneX = ViewImageConfig.iPhoneX(.portrait) + + public static func iPhoneX(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) + size = .init(width: 812, height: 375) + case .portrait: + safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) + size = .init(width: 375, height: 812) + } + return .init(safeArea: safeArea, size: size, traits: .iPhoneX(orientation)) + } - @available(iOS 11.0, *) - public static let iPhoneXr = ViewImageConfig.iPhoneXr(.portrait) - - @available(iOS 11.0, *) - public static func iPhoneXr(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) - size = .init(width: 896, height: 414) - case .portrait: - safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) - size = .init(width: 414, height: 896) - } - return .init(safeArea: safeArea, size: size, traits: .iPhoneXr(orientation)) - } + public static let iPhoneXsMax = ViewImageConfig.iPhoneXsMax(.portrait) + + public static func iPhoneXsMax(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) + size = .init(width: 896, height: 414) + case .portrait: + safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) + size = .init(width: 414, height: 896) + } + return .init(safeArea: safeArea, size: size, traits: .iPhoneXsMax(orientation)) + } - public static let iPhone12 = ViewImageConfig.iPhone12(.portrait) - - public static func iPhone12(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) - size = .init(width: 844, height: 390) - case .portrait: - safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) - size = .init(width: 390, height: 844) - } - return .init(safeArea: safeArea, size: size, traits: .iPhone12(orientation)) - } + @available(iOS 11.0, *) + public static let iPhoneXr = ViewImageConfig.iPhoneXr(.portrait) + + @available(iOS 11.0, *) + public static func iPhoneXr(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 44, bottom: 24, right: 44) + size = .init(width: 896, height: 414) + case .portrait: + safeArea = .init(top: 44, left: 0, bottom: 34, right: 0) + size = .init(width: 414, height: 896) + } + return .init(safeArea: safeArea, size: size, traits: .iPhoneXr(orientation)) + } - public static let iPhone12Pro = ViewImageConfig.iPhone12Pro(.portrait) + public static let iPhone12 = ViewImageConfig.iPhone12(.portrait) + + public static func iPhone12(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) + size = .init(width: 844, height: 390) + case .portrait: + safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) + size = .init(width: 390, height: 844) + } + return .init(safeArea: safeArea, size: size, traits: .iPhone12(orientation)) + } - public static func iPhone12Pro(_ orientation: Orientation) -> ViewImageConfig { - .iPhone12(orientation) - } + public static let iPhone12Pro = ViewImageConfig.iPhone12Pro(.portrait) - public static let iPhone12ProMax = ViewImageConfig.iPhone12ProMax(.portrait) - - public static func iPhone12ProMax(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) - size = .init(width: 926, height: 428) - case .portrait: - safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) - size = .init(width: 428, height: 926) - } - return .init(safeArea: safeArea, size: size, traits: .iPhone12ProMax(orientation)) - } + public static func iPhone12Pro(_ orientation: Orientation) -> ViewImageConfig { + .iPhone12(orientation) + } - public static let iPhone13 = ViewImageConfig.iPhone13(.portrait) - - public static func iPhone13(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) - size = .init(width: 844, height: 390) - case .portrait: - safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) - size = .init(width: 390, height: 844) - } + public static let iPhone12ProMax = ViewImageConfig.iPhone12ProMax(.portrait) + + public static func iPhone12ProMax(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) + size = .init(width: 926, height: 428) + case .portrait: + safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) + size = .init(width: 428, height: 926) + } + return .init(safeArea: safeArea, size: size, traits: .iPhone12ProMax(orientation)) + } - return .init(safeArea: safeArea, size: size, traits: UITraitCollection.iPhone13(orientation)) - } + public static let iPhone13 = ViewImageConfig.iPhone13(.portrait) + + public static func iPhone13(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) + size = .init(width: 844, height: 390) + case .portrait: + safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) + size = .init(width: 390, height: 844) + } - public static let iPhone13Mini = ViewImageConfig.iPhone13Mini(.portrait) - - public static func iPhone13Mini(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 50, bottom: 21, right: 50) - size = .init(width: 812, height: 375) - case .portrait: - safeArea = .init(top: 50, left: 0, bottom: 34, right: 0) - size = .init(width: 375, height: 812) - } + return .init( + safeArea: safeArea, size: size, traits: UITraitCollection.iPhone13(orientation)) + } - return .init(safeArea: safeArea, size: size, traits: .iPhone13(orientation)) - } + public static let iPhone13Mini = ViewImageConfig.iPhone13Mini(.portrait) + + public static func iPhone13Mini(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 50, bottom: 21, right: 50) + size = .init(width: 812, height: 375) + case .portrait: + safeArea = .init(top: 50, left: 0, bottom: 34, right: 0) + size = .init(width: 375, height: 812) + } - public static let iPhone13Pro = ViewImageConfig.iPhone13Pro(.portrait) + return .init(safeArea: safeArea, size: size, traits: .iPhone13(orientation)) + } - public static func iPhone13Pro(_ orientation: Orientation) -> ViewImageConfig { - .iPhone13(orientation) - } + public static let iPhone13Pro = ViewImageConfig.iPhone13Pro(.portrait) - public static let iPhone13ProMax = ViewImageConfig.iPhone13ProMax(.portrait) - - public static func iPhone13ProMax(_ orientation: Orientation) -> ViewImageConfig { - let safeArea: UIEdgeInsets - let size: CGSize - switch orientation { - case .landscape: - safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) - size = .init(width: 926, height: 428) - case .portrait: - safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) - size = .init(width: 428, height: 926) - } + public static func iPhone13Pro(_ orientation: Orientation) -> ViewImageConfig { + .iPhone13(orientation) + } - return .init(safeArea: safeArea, size: size, traits: .iPhone13ProMax(orientation)) - } + public static let iPhone13ProMax = ViewImageConfig.iPhone13ProMax(.portrait) + + public static func iPhone13ProMax(_ orientation: Orientation) -> ViewImageConfig { + let safeArea: UIEdgeInsets + let size: CGSize + switch orientation { + case .landscape: + safeArea = .init(top: 0, left: 47, bottom: 21, right: 47) + size = .init(width: 926, height: 428) + case .portrait: + safeArea = .init(top: 47, left: 0, bottom: 34, right: 0) + size = .init(width: 428, height: 926) + } - public static let iPadMini = ViewImageConfig.iPadMini(.landscape) + return .init(safeArea: safeArea, size: size, traits: .iPhone13ProMax(orientation)) + } - public static func iPadMini(_ orientation: Orientation) -> ViewImageConfig { - switch orientation { - case .landscape: - return ViewImageConfig.iPadMini(.landscape(splitView: .full)) - case .portrait: - return ViewImageConfig.iPadMini(.portrait(splitView: .full)) - } - } + public static let iPadMini = ViewImageConfig.iPadMini(.landscape) - public static func iPadMini(_ orientation: TabletOrientation) -> ViewImageConfig { - let size: CGSize - let traits: UITraitCollection - switch orientation { - case .landscape(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 320, height: 768) - traits = .iPadMini_Compact_SplitView - case .oneHalf: - size = .init(width: 507, height: 768) - traits = .iPadMini_Compact_SplitView - case .twoThirds: - size = .init(width: 694, height: 768) - traits = .iPadMini - case .full: - size = .init(width: 1024, height: 768) - traits = .iPadMini - } - case .portrait(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 320, height: 1024) - traits = .iPadMini_Compact_SplitView - case .twoThirds: - size = .init(width: 438, height: 1024) - traits = .iPadMini_Compact_SplitView - case .full: - size = .init(width: 768, height: 1024) - traits = .iPadMini - } - } - return .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) - } + public static func iPadMini(_ orientation: Orientation) -> ViewImageConfig { + switch orientation { + case .landscape: + return ViewImageConfig.iPadMini(.landscape(splitView: .full)) + case .portrait: + return ViewImageConfig.iPadMini(.portrait(splitView: .full)) + } + } - public static let iPad9_7 = iPadMini + public static func iPadMini(_ orientation: TabletOrientation) -> ViewImageConfig { + let size: CGSize + let traits: UITraitCollection + switch orientation { + case .landscape(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 320, height: 768) + traits = .iPadMini_Compact_SplitView + case .oneHalf: + size = .init(width: 507, height: 768) + traits = .iPadMini_Compact_SplitView + case .twoThirds: + size = .init(width: 694, height: 768) + traits = .iPadMini + case .full: + size = .init(width: 1024, height: 768) + traits = .iPadMini + } + case .portrait(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 320, height: 1024) + traits = .iPadMini_Compact_SplitView + case .twoThirds: + size = .init(width: 438, height: 1024) + traits = .iPadMini_Compact_SplitView + case .full: + size = .init(width: 768, height: 1024) + traits = .iPadMini + } + } + return .init( + safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) + } - public static func iPad9_7(_ orientation: Orientation) -> ViewImageConfig { - return iPadMini(orientation) - } + public static let iPad9_7 = iPadMini - public static func iPad9_7(_ orientation: TabletOrientation) -> ViewImageConfig { - return iPadMini(orientation) - } + public static func iPad9_7(_ orientation: Orientation) -> ViewImageConfig { + return iPadMini(orientation) + } - public static let iPad10_2 = ViewImageConfig.iPad10_2(.landscape) + public static func iPad9_7(_ orientation: TabletOrientation) -> ViewImageConfig { + return iPadMini(orientation) + } - public static func iPad10_2(_ orientation: Orientation) -> ViewImageConfig { - switch orientation { - case .landscape: - return ViewImageConfig.iPad10_2(.landscape(splitView: .full)) - case .portrait: - return ViewImageConfig.iPad10_2(.portrait(splitView: .full)) - } - } + public static let iPad10_2 = ViewImageConfig.iPad10_2(.landscape) - public static func iPad10_2(_ orientation: TabletOrientation) -> ViewImageConfig { - let size: CGSize - let traits: UITraitCollection - switch orientation { - case .landscape(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 320, height: 810) - traits = .iPad10_2_Compact_SplitView - case .oneHalf: - size = .init(width: 535, height: 810) - traits = .iPad10_2_Compact_SplitView - case .twoThirds: - size = .init(width: 750, height: 810) - traits = .iPad10_2 - case .full: - size = .init(width: 1080, height: 810) - traits = .iPad10_2 - } - case .portrait(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 320, height: 1080) - traits = .iPad10_2_Compact_SplitView - case .twoThirds: - size = .init(width: 480, height: 1080) - traits = .iPad10_2_Compact_SplitView - case .full: - size = .init(width: 810, height: 1080) - traits = .iPad10_2 - } - } - return .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) - } + public static func iPad10_2(_ orientation: Orientation) -> ViewImageConfig { + switch orientation { + case .landscape: + return ViewImageConfig.iPad10_2(.landscape(splitView: .full)) + case .portrait: + return ViewImageConfig.iPad10_2(.portrait(splitView: .full)) + } + } + public static func iPad10_2(_ orientation: TabletOrientation) -> ViewImageConfig { + let size: CGSize + let traits: UITraitCollection + switch orientation { + case .landscape(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 320, height: 810) + traits = .iPad10_2_Compact_SplitView + case .oneHalf: + size = .init(width: 535, height: 810) + traits = .iPad10_2_Compact_SplitView + case .twoThirds: + size = .init(width: 750, height: 810) + traits = .iPad10_2 + case .full: + size = .init(width: 1080, height: 810) + traits = .iPad10_2 + } + case .portrait(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 320, height: 1080) + traits = .iPad10_2_Compact_SplitView + case .twoThirds: + size = .init(width: 480, height: 1080) + traits = .iPad10_2_Compact_SplitView + case .full: + size = .init(width: 810, height: 1080) + traits = .iPad10_2 + } + } + return .init( + safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) + } - public static let iPadPro10_5 = ViewImageConfig.iPadPro10_5(.landscape) + public static let iPadPro10_5 = ViewImageConfig.iPadPro10_5(.landscape) - public static func iPadPro10_5(_ orientation: Orientation) -> ViewImageConfig { - switch orientation { - case .landscape: - return ViewImageConfig.iPadPro10_5(.landscape(splitView: .full)) - case .portrait: - return ViewImageConfig.iPadPro10_5(.portrait(splitView: .full)) - } - } + public static func iPadPro10_5(_ orientation: Orientation) -> ViewImageConfig { + switch orientation { + case .landscape: + return ViewImageConfig.iPadPro10_5(.landscape(splitView: .full)) + case .portrait: + return ViewImageConfig.iPadPro10_5(.portrait(splitView: .full)) + } + } - public static func iPadPro10_5(_ orientation: TabletOrientation) -> ViewImageConfig { - let size: CGSize - let traits: UITraitCollection - switch orientation { - case .landscape(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 320, height: 834) - traits = .iPadPro10_5_Compact_SplitView - case .oneHalf: - size = .init(width: 551, height: 834) - traits = .iPadPro10_5_Compact_SplitView - case .twoThirds: - size = .init(width: 782, height: 834) - traits = .iPadPro10_5 - case .full: - size = .init(width: 1112, height: 834) - traits = .iPadPro10_5 - } - case .portrait(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 320, height: 1112) - traits = .iPadPro10_5_Compact_SplitView - case .twoThirds: - size = .init(width: 504, height: 1112) - traits = .iPadPro10_5_Compact_SplitView - case .full: - size = .init(width: 834, height: 1112) - traits = .iPadPro10_5 - } - } - return .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) - } + public static func iPadPro10_5(_ orientation: TabletOrientation) -> ViewImageConfig { + let size: CGSize + let traits: UITraitCollection + switch orientation { + case .landscape(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 320, height: 834) + traits = .iPadPro10_5_Compact_SplitView + case .oneHalf: + size = .init(width: 551, height: 834) + traits = .iPadPro10_5_Compact_SplitView + case .twoThirds: + size = .init(width: 782, height: 834) + traits = .iPadPro10_5 + case .full: + size = .init(width: 1112, height: 834) + traits = .iPadPro10_5 + } + case .portrait(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 320, height: 1112) + traits = .iPadPro10_5_Compact_SplitView + case .twoThirds: + size = .init(width: 504, height: 1112) + traits = .iPadPro10_5_Compact_SplitView + case .full: + size = .init(width: 834, height: 1112) + traits = .iPadPro10_5 + } + } + return .init( + safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) + } - public static let iPadPro11 = ViewImageConfig.iPadPro11(.landscape) + public static let iPadPro11 = ViewImageConfig.iPadPro11(.landscape) - public static func iPadPro11(_ orientation: Orientation) -> ViewImageConfig { - switch orientation { - case .landscape: - return ViewImageConfig.iPadPro11(.landscape(splitView: .full)) - case .portrait: - return ViewImageConfig.iPadPro11(.portrait(splitView: .full)) - } - } + public static func iPadPro11(_ orientation: Orientation) -> ViewImageConfig { + switch orientation { + case .landscape: + return ViewImageConfig.iPadPro11(.landscape(splitView: .full)) + case .portrait: + return ViewImageConfig.iPadPro11(.portrait(splitView: .full)) + } + } - public static func iPadPro11(_ orientation: TabletOrientation) -> ViewImageConfig { - let size: CGSize - let traits: UITraitCollection - switch orientation { - case .landscape(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 375, height: 834) - traits = .iPadPro11_Compact_SplitView - case .oneHalf: - size = .init(width: 592, height: 834) - traits = .iPadPro11_Compact_SplitView - case .twoThirds: - size = .init(width: 809, height: 834) - traits = .iPadPro11 - case .full: - size = .init(width: 1194, height: 834) - traits = .iPadPro11 - } - case .portrait(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 320, height: 1194) - traits = .iPadPro11_Compact_SplitView - case .twoThirds: - size = .init(width: 504, height: 1194) - traits = .iPadPro11_Compact_SplitView - case .full: - size = .init(width: 834, height: 1194) - traits = .iPadPro11 - } - } - return .init(safeArea: .init(top: 24, left: 0, bottom: 20, right: 0), size: size, traits: traits) - } + public static func iPadPro11(_ orientation: TabletOrientation) -> ViewImageConfig { + let size: CGSize + let traits: UITraitCollection + switch orientation { + case .landscape(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 375, height: 834) + traits = .iPadPro11_Compact_SplitView + case .oneHalf: + size = .init(width: 592, height: 834) + traits = .iPadPro11_Compact_SplitView + case .twoThirds: + size = .init(width: 809, height: 834) + traits = .iPadPro11 + case .full: + size = .init(width: 1194, height: 834) + traits = .iPadPro11 + } + case .portrait(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 320, height: 1194) + traits = .iPadPro11_Compact_SplitView + case .twoThirds: + size = .init(width: 504, height: 1194) + traits = .iPadPro11_Compact_SplitView + case .full: + size = .init(width: 834, height: 1194) + traits = .iPadPro11 + } + } + return .init( + safeArea: .init(top: 24, left: 0, bottom: 20, right: 0), size: size, traits: traits) + } - public static let iPadPro12_9 = ViewImageConfig.iPadPro12_9(.landscape) + public static let iPadPro12_9 = ViewImageConfig.iPadPro12_9(.landscape) - public static func iPadPro12_9(_ orientation: Orientation) -> ViewImageConfig { - switch orientation { - case .landscape: - return ViewImageConfig.iPadPro12_9(.landscape(splitView: .full)) - case .portrait: - return ViewImageConfig.iPadPro12_9(.portrait(splitView: .full)) - } - } + public static func iPadPro12_9(_ orientation: Orientation) -> ViewImageConfig { + switch orientation { + case .landscape: + return ViewImageConfig.iPadPro12_9(.landscape(splitView: .full)) + case .portrait: + return ViewImageConfig.iPadPro12_9(.portrait(splitView: .full)) + } + } - public static func iPadPro12_9(_ orientation: TabletOrientation) -> ViewImageConfig { - let size: CGSize - let traits: UITraitCollection - switch orientation { - case .landscape(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 375, height: 1024) - traits = .iPadPro12_9_Compact_SplitView - case .oneHalf: - size = .init(width: 678, height: 1024) - traits = .iPadPro12_9 - case .twoThirds: - size = .init(width: 981, height: 1024) - traits = .iPadPro12_9 - case .full: - size = .init(width: 1366, height: 1024) - traits = .iPadPro12_9 - } + public static func iPadPro12_9(_ orientation: TabletOrientation) -> ViewImageConfig { + let size: CGSize + let traits: UITraitCollection + switch orientation { + case .landscape(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 375, height: 1024) + traits = .iPadPro12_9_Compact_SplitView + case .oneHalf: + size = .init(width: 678, height: 1024) + traits = .iPadPro12_9 + case .twoThirds: + size = .init(width: 981, height: 1024) + traits = .iPadPro12_9 + case .full: + size = .init(width: 1366, height: 1024) + traits = .iPadPro12_9 + } - case .portrait(let splitView): - switch splitView { - case .oneThird: - size = .init(width: 375, height: 1366) - traits = .iPadPro12_9_Compact_SplitView - case .twoThirds: - size = .init(width: 639, height: 1366) - traits = .iPadPro12_9_Compact_SplitView - case .full: - size = .init(width: 1024, height: 1366) - traits = .iPadPro12_9 - } + case .portrait(let splitView): + switch splitView { + case .oneThird: + size = .init(width: 375, height: 1366) + traits = .iPadPro12_9_Compact_SplitView + case .twoThirds: + size = .init(width: 639, height: 1366) + traits = .iPadPro12_9_Compact_SplitView + case .full: + size = .init(width: 1024, height: 1366) + traits = .iPadPro12_9 + } + } + return .init( + safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) + } + #elseif os(tvOS) + public static let tv = ViewImageConfig( + safeArea: .init(top: 60, left: 90, bottom: 60, right: 90), + size: .init(width: 1920, height: 1080), + traits: .init() + ) + public static let tv4K = ViewImageConfig( + safeArea: .init(top: 120, left: 180, bottom: 120, right: 180), + size: .init(width: 3840, height: 2160), + traits: .init() + ) + #endif } - return .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0), size: size, traits: traits) - } - #elseif os(tvOS) - public static let tv = ViewImageConfig( - safeArea: .init(top: 60, left: 90, bottom: 60, right: 90), - size: .init(width: 1920, height: 1080), - traits: .init() - ) - public static let tv4K = ViewImageConfig( - safeArea: .init(top: 120, left: 180, bottom: 120, right: 180), - size: .init(width: 3840, height: 2160), - traits: .init() - ) - #endif -} -extension UITraitCollection { - #if os(iOS) - public static func iPhoneSe(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .compact) + extension UITraitCollection { + #if os(iOS) + public static func iPhoneSe(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular), + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } + + public static func iPhone8(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - } - } + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } - public static func iPhone8(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .compact) + public static func iPhone8Plus(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .regular), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } + + public static func iPhoneX(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - } - } + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } - public static func iPhone8Plus(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .regular), - .init(verticalSizeClass: .compact) + public static func iPhoneXr(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .unavailable), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .regular), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } + + public static func iPhoneXsMax(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - } - } + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .regular), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } - public static func iPhoneX(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .compact) + public static func iPhone12(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } + + public static func iPhone12ProMax(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - } - } + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .regular), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } - public static func iPhoneXr(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .unavailable), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .regular), - .init(verticalSizeClass: .compact) + public static func iPhone13(_ orientation: ViewImageConfig.Orientation) -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } + + public static func iPhone13ProMax(_ orientation: ViewImageConfig.Orientation) + -> UITraitCollection + { + let base: [UITraitCollection] = [ + .init(forceTouchCapability: .available), + .init(layoutDirection: .leftToRight), + .init(preferredContentSizeCategory: .medium), + .init(userInterfaceIdiom: .phone), ] - ) - } - } + switch orientation { + case .landscape: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .regular), + .init(verticalSizeClass: .compact), + ] + ) + case .portrait: + return .init( + traitsFrom: base + [ + .init(horizontalSizeClass: .compact), + .init(verticalSizeClass: .regular), + ] + ) + } + } - public static func iPhoneXsMax(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ + public static let iPadMini = iPad + public static let iPadMini_Compact_SplitView = iPadCompactSplitView + public static let iPad9_7 = iPad + public static let iPad9_7_Compact_SplitView = iPadCompactSplitView + public static let iPad10_2 = iPad + public static let iPad10_2_Compact_SplitView = iPadCompactSplitView + public static let iPadPro10_5 = iPad + public static let iPadPro10_5_Compact_SplitView = iPadCompactSplitView + public static let iPadPro11 = iPad + public static let iPadPro11_Compact_SplitView = iPadCompactSplitView + public static let iPadPro12_9 = iPad + public static let iPadPro12_9_Compact_SplitView = iPadCompactSplitView + + private static let iPad = UITraitCollection( + traitsFrom: [ + // .init(displayScale: 2), .init(horizontalSizeClass: .regular), - .init(verticalSizeClass: .compact) + .init(verticalSizeClass: .regular), + .init(userInterfaceIdiom: .pad), ] ) - case .portrait: - return .init( - traitsFrom: base + [ + + private static let iPadCompactSplitView = UITraitCollection( + traitsFrom: [ .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) + .init(verticalSizeClass: .regular), + .init(userInterfaceIdiom: .pad), ] ) - } - } - - public static func iPhone12(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .compact) - ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) - ] - ) - } - } - - public static func iPhone12ProMax(_ orientation: ViewImageConfig.Orientation) - -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .regular), - .init(verticalSizeClass: .compact) - ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) - ] - ) - } - } - - public static func iPhone13(_ orientation: ViewImageConfig.Orientation) -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .compact) - ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) - ] - ) - } - } - - public static func iPhone13ProMax(_ orientation: ViewImageConfig.Orientation) -> UITraitCollection { - let base: [UITraitCollection] = [ - .init(forceTouchCapability: .available), - .init(layoutDirection: .leftToRight), - .init(preferredContentSizeCategory: .medium), - .init(userInterfaceIdiom: .phone) - ] - switch orientation { - case .landscape: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .regular), - .init(verticalSizeClass: .compact) - ] - ) - case .portrait: - return .init( - traitsFrom: base + [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular) - ] - ) + #elseif os(tvOS) + // TODO + #endif } - } - - public static let iPadMini = iPad - public static let iPadMini_Compact_SplitView = iPadCompactSplitView - public static let iPad9_7 = iPad - public static let iPad9_7_Compact_SplitView = iPadCompactSplitView - public static let iPad10_2 = iPad - public static let iPad10_2_Compact_SplitView = iPadCompactSplitView - public static let iPadPro10_5 = iPad - public static let iPadPro10_5_Compact_SplitView = iPadCompactSplitView - public static let iPadPro11 = iPad - public static let iPadPro11_Compact_SplitView = iPadCompactSplitView - public static let iPadPro12_9 = iPad - public static let iPadPro12_9_Compact_SplitView = iPadCompactSplitView - - private static let iPad = UITraitCollection( - traitsFrom: [ -// .init(displayScale: 2), - .init(horizontalSizeClass: .regular), - .init(verticalSizeClass: .regular), - .init(userInterfaceIdiom: .pad) - ] - ) - - private static let iPadCompactSplitView = UITraitCollection( - traitsFrom: [ - .init(horizontalSizeClass: .compact), - .init(verticalSizeClass: .regular), - .init(userInterfaceIdiom: .pad) - ] - ) - #elseif os(tvOS) - // TODO #endif -} -#endif -func addImagesForRenderedViews(_ view: View) -> [Async] { - return view.snapshot - .map { async in - [ - Async { callback in - async.run { image in - let imageView = ImageView() - imageView.image = image - imageView.frame = view.frame - #if os(macOS) - view.superview?.addSubview(imageView, positioned: .above, relativeTo: view) - #elseif os(iOS) || os(tvOS) - view.superview?.insertSubview(imageView, aboveSubview: view) - #endif - callback(imageView) + func addImagesForRenderedViews(_ view: View) -> [Async] { + return view.snapshot + .map { async in + [ + Async { callback in + async.run { image in + let imageView = ImageView() + imageView.image = image + imageView.frame = view.frame + #if os(macOS) + view.superview?.addSubview(imageView, positioned: .above, relativeTo: view) + #elseif os(iOS) || os(tvOS) + view.superview?.insertSubview(imageView, aboveSubview: view) + #endif + callback(imageView) + } } - } - ] - } - ?? view.subviews.flatMap(addImagesForRenderedViews) -} + ] + } + ?? view.subviews.flatMap(addImagesForRenderedViews) + } -extension View { - var snapshot: Async? { - func inWindow(_ perform: () -> T) -> T { - #if os(macOS) - let superview = self.superview - defer { superview?.addSubview(self) } - let window = ScaledWindow() - window.contentView = NSView() - window.contentView?.addSubview(self) - window.makeKey() - #endif - return perform() - } - if let scnView = self as? SCNView { - return Async(value: inWindow { scnView.snapshot() }) - } else if let skView = self as? SKView { - if #available(macOS 10.11, *) { - let cgImage = inWindow { skView.texture(from: skView.scene!)!.cgImage() } + extension View { + var snapshot: Async? { + func inWindow(_ perform: () -> T) -> T { #if os(macOS) - let image = Image(cgImage: cgImage, size: skView.bounds.size) - #elseif os(iOS) || os(tvOS) - let image = Image(cgImage: cgImage) + let superview = self.superview + defer { superview?.addSubview(self) } + let window = ScaledWindow() + window.contentView = NSView() + window.contentView?.addSubview(self) + window.makeKey() #endif - return Async(value: image) - } else { - fatalError("Taking SKView snapshots requires macOS 10.11 or greater") + return perform() } - } - #if os(iOS) || os(macOS) - if let wkWebView = self as? WKWebView { - return Async { callback in - let work = { - if #available(iOS 11.0, macOS 10.13, *) { - inWindow { - guard wkWebView.frame.width != 0, wkWebView.frame.height != 0 else { - callback(Image()) - return - } - wkWebView.takeSnapshot(with: nil) { image, _ in - callback(image!) + if let scnView = self as? SCNView { + return Async(value: inWindow { scnView.snapshot() }) + } else if let skView = self as? SKView { + if #available(macOS 10.11, *) { + let cgImage = inWindow { skView.texture(from: skView.scene!)!.cgImage() } + #if os(macOS) + let image = Image(cgImage: cgImage, size: skView.bounds.size) + #elseif os(iOS) || os(tvOS) + let image = Image(cgImage: cgImage) + #endif + return Async(value: image) + } else { + fatalError("Taking SKView snapshots requires macOS 10.11 or greater") + } + } + #if os(iOS) || os(macOS) + if let wkWebView = self as? WKWebView { + return Async { callback in + let work = { + if #available(iOS 11.0, macOS 10.13, *) { + inWindow { + guard wkWebView.frame.width != 0, wkWebView.frame.height != 0 else { + callback(Image()) + return + } + wkWebView.takeSnapshot(with: nil) { image, _ in + callback(image!) + } + } + } else { + #if os(iOS) + fatalError("Taking WKWebView snapshots requires iOS 11.0 or greater") + #elseif os(macOS) + fatalError("Taking WKWebView snapshots requires macOS 10.13 or greater") + #endif } } - } else { - #if os(iOS) - fatalError("Taking WKWebView snapshots requires iOS 11.0 or greater") - #elseif os(macOS) - fatalError("Taking WKWebView snapshots requires macOS 10.13 or greater") - #endif - } - } - if wkWebView.isLoading { - var subscription: NSKeyValueObservation? - subscription = wkWebView.observe(\.isLoading, options: [.initial, .new]) { (webview, change) in - subscription?.invalidate() - subscription = nil - if change.newValue == false { + if wkWebView.isLoading { + var subscription: NSKeyValueObservation? + subscription = wkWebView.observe(\.isLoading, options: [.initial, .new]) { + (webview, change) in + subscription?.invalidate() + subscription = nil + if change.newValue == false { + work() + } + } + } else { work() } } - } else { - work() } - } + #endif + return nil } + #if os(iOS) || os(tvOS) + func asImage() -> Image { + let renderer = UIGraphicsImageRenderer(bounds: bounds) + return renderer.image { rendererContext in + layer.render(in: rendererContext.cgContext) + } + } #endif - return nil } + #if os(iOS) || os(tvOS) - func asImage() -> Image { - let renderer = UIGraphicsImageRenderer(bounds: bounds) - return renderer.image { rendererContext in - layer.render(in: rendererContext.cgContext) + extension UIApplication { + static var sharedIfAvailable: UIApplication? { + let sharedSelector = NSSelectorFromString("sharedApplication") + guard UIApplication.responds(to: sharedSelector) else { + return nil + } + + let shared = UIApplication.perform(sharedSelector) + return shared?.takeUnretainedValue() as! UIApplication? + } } - } - #endif -} -#if os(iOS) || os(tvOS) -extension UIApplication { - static var sharedIfAvailable: UIApplication? { - let sharedSelector = NSSelectorFromString("sharedApplication") - guard UIApplication.responds(to: sharedSelector) else { - return nil + func prepareView( + config: ViewImageConfig, + drawHierarchyInKeyWindow: Bool, + traits: UITraitCollection, + view: UIView, + viewController: UIViewController + ) -> () -> Void { + let size = config.size ?? viewController.view.frame.size + view.frame.size = size + if view != viewController.view { + viewController.view.bounds = view.bounds + viewController.view.addSubview(view) + } + let traits = UITraitCollection(traitsFrom: [config.traits, traits]) + let window: UIWindow + if drawHierarchyInKeyWindow { + guard let keyWindow = getKeyWindow() else { + fatalError("'drawHierarchyInKeyWindow' requires tests to be run in a host application") + } + window = keyWindow + window.frame.size = size + } else { + window = Window( + config: .init(safeArea: config.safeArea, size: config.size ?? size, traits: traits), + viewController: viewController + ) } + let dispose = add(traits: traits, viewController: viewController, to: window) - let shared = UIApplication.perform(sharedSelector) - return shared?.takeUnretainedValue() as! UIApplication? - } -} + if size.width == 0 || size.height == 0 { + // Try to call sizeToFit() if the view still has invalid size + view.sizeToFit() + view.setNeedsLayout() + view.layoutIfNeeded() + } -func prepareView( - config: ViewImageConfig, - drawHierarchyInKeyWindow: Bool, - traits: UITraitCollection, - view: UIView, - viewController: UIViewController - ) -> () -> Void { - let size = config.size ?? viewController.view.frame.size - view.frame.size = size - if view != viewController.view { - viewController.view.bounds = view.bounds - viewController.view.addSubview(view) - } - let traits = UITraitCollection(traitsFrom: [config.traits, traits]) - let window: UIWindow - if drawHierarchyInKeyWindow { - guard let keyWindow = getKeyWindow() else { - fatalError("'drawHierarchyInKeyWindow' requires tests to be run in a host application") + return dispose } - window = keyWindow - window.frame.size = size - } else { - window = Window( - config: .init(safeArea: config.safeArea, size: config.size ?? size, traits: traits), - viewController: viewController - ) - } - let dispose = add(traits: traits, viewController: viewController, to: window) - - if size.width == 0 || size.height == 0 { - // Try to call sizeToFit() if the view still has invalid size - view.sizeToFit() - view.setNeedsLayout() - view.layoutIfNeeded() - } - return dispose -} - -func snapshotView( - config: ViewImageConfig, - drawHierarchyInKeyWindow: Bool, - traits: UITraitCollection, - view: UIView, - viewController: UIViewController - ) - -> Async { - let initialFrame = view.frame - let dispose = prepareView( - config: config, - drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, - traits: traits, - view: view, - viewController: viewController + func snapshotView( + config: ViewImageConfig, + drawHierarchyInKeyWindow: Bool, + traits: UITraitCollection, + view: UIView, + viewController: UIViewController ) - // NB: Avoid safe area influence. - if config.safeArea == .zero { view.frame.origin = .init(x: offscreen, y: offscreen) } - - return (view.snapshot ?? Async { callback in - addImagesForRenderedViews(view).sequence().run { views in - callback( - renderer(bounds: view.bounds, for: traits).image { ctx in - if drawHierarchyInKeyWindow { - view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) - } else { - view.layer.render(in: ctx.cgContext) - } + -> Async + { + let initialFrame = view.frame + let dispose = prepareView( + config: config, + drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, + traits: traits, + view: view, + viewController: viewController + ) + // NB: Avoid safe area influence. + if config.safeArea == .zero { view.frame.origin = .init(x: offscreen, y: offscreen) } + + return + (view.snapshot + ?? Async { callback in + addImagesForRenderedViews(view).sequence().run { views in + callback( + renderer(bounds: view.bounds, for: traits).image { ctx in + if drawHierarchyInKeyWindow { + view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) + } else { + view.layer.render(in: ctx.cgContext) + } + } + ) + views.forEach { $0.removeFromSuperview() } + view.frame = initialFrame } - ) - views.forEach { $0.removeFromSuperview() } - view.frame = initialFrame - } - }).map { dispose(); return $0 } -} - -private let offscreen: CGFloat = 10_000 + }).map { + dispose() + return $0 + } + } -func renderer(bounds: CGRect, for traits: UITraitCollection) -> UIGraphicsImageRenderer { - let renderer: UIGraphicsImageRenderer - if #available(iOS 11.0, tvOS 11.0, *) { - renderer = UIGraphicsImageRenderer(bounds: bounds, format: .init(for: traits)) - } else { - renderer = UIGraphicsImageRenderer(bounds: bounds) - } - return renderer -} + private let offscreen: CGFloat = 10_000 -private func add(traits: UITraitCollection, viewController: UIViewController, to window: UIWindow) -> () -> Void { - let rootViewController: UIViewController - if viewController != window.rootViewController { - rootViewController = UIViewController() - rootViewController.view.backgroundColor = .clear - rootViewController.view.frame = window.frame - rootViewController.view.translatesAutoresizingMaskIntoConstraints = - viewController.view.translatesAutoresizingMaskIntoConstraints - rootViewController.preferredContentSize = rootViewController.view.frame.size - viewController.view.frame = rootViewController.view.frame - rootViewController.view.addSubview(viewController.view) - if viewController.view.translatesAutoresizingMaskIntoConstraints { - viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - } else { - NSLayoutConstraint.activate([ - viewController.view.topAnchor.constraint(equalTo: rootViewController.view.topAnchor), - viewController.view.bottomAnchor.constraint(equalTo: rootViewController.view.bottomAnchor), - viewController.view.leadingAnchor.constraint(equalTo: rootViewController.view.leadingAnchor), - viewController.view.trailingAnchor.constraint(equalTo: rootViewController.view.trailingAnchor), - ]) + func renderer(bounds: CGRect, for traits: UITraitCollection) -> UIGraphicsImageRenderer { + let renderer: UIGraphicsImageRenderer + if #available(iOS 11.0, tvOS 11.0, *) { + renderer = UIGraphicsImageRenderer(bounds: bounds, format: .init(for: traits)) + } else { + renderer = UIGraphicsImageRenderer(bounds: bounds) + } + return renderer } - rootViewController.addChild(viewController) - } else { - rootViewController = viewController - } - rootViewController.setOverrideTraitCollection(traits, forChild: viewController) - viewController.didMove(toParent: rootViewController) - window.rootViewController = rootViewController + private func add( + traits: UITraitCollection, viewController: UIViewController, to window: UIWindow + ) -> () -> Void { + let rootViewController: UIViewController + if viewController != window.rootViewController { + rootViewController = UIViewController() + rootViewController.view.backgroundColor = .clear + rootViewController.view.frame = window.frame + rootViewController.view.translatesAutoresizingMaskIntoConstraints = + viewController.view.translatesAutoresizingMaskIntoConstraints + rootViewController.preferredContentSize = rootViewController.view.frame.size + viewController.view.frame = rootViewController.view.frame + rootViewController.view.addSubview(viewController.view) + if viewController.view.translatesAutoresizingMaskIntoConstraints { + viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } else { + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: rootViewController.view.topAnchor), + viewController.view.bottomAnchor.constraint( + equalTo: rootViewController.view.bottomAnchor), + viewController.view.leadingAnchor.constraint( + equalTo: rootViewController.view.leadingAnchor), + viewController.view.trailingAnchor.constraint( + equalTo: rootViewController.view.trailingAnchor), + ]) + } + rootViewController.addChild(viewController) + } else { + rootViewController = viewController + } + rootViewController.setOverrideTraitCollection(traits, forChild: viewController) + viewController.didMove(toParent: rootViewController) - rootViewController.beginAppearanceTransition(true, animated: false) - rootViewController.endAppearanceTransition() + window.rootViewController = rootViewController - rootViewController.view.setNeedsLayout() - rootViewController.view.layoutIfNeeded() + rootViewController.beginAppearanceTransition(true, animated: false) + rootViewController.endAppearanceTransition() - viewController.view.setNeedsLayout() - viewController.view.layoutIfNeeded() + rootViewController.view.setNeedsLayout() + rootViewController.view.layoutIfNeeded() - return { - rootViewController.beginAppearanceTransition(false, animated: false) - viewController.willMove(toParent: nil) - viewController.view.removeFromSuperview() - viewController.removeFromParent() - viewController.didMove(toParent: nil) - rootViewController.endAppearanceTransition() - window.rootViewController = nil - } -} + viewController.view.setNeedsLayout() + viewController.view.layoutIfNeeded() -private func getKeyWindow() -> UIWindow? { - var window: UIWindow? - if #available(iOS 13.0, *) { - window = UIApplication.sharedIfAvailable?.windows.first { $0.isKeyWindow } - } else { - window = UIApplication.sharedIfAvailable?.keyWindow - } - return window -} + return { + rootViewController.beginAppearanceTransition(false, animated: false) + viewController.willMove(toParent: nil) + viewController.view.removeFromSuperview() + viewController.removeFromParent() + viewController.didMove(toParent: nil) + rootViewController.endAppearanceTransition() + window.rootViewController = nil + } + } -private final class Window: UIWindow { - var config: ViewImageConfig - - init(config: ViewImageConfig, viewController: UIViewController) { - let size = config.size ?? viewController.view.bounds.size - self.config = config - super.init(frame: .init(origin: .zero, size: size)) - - // NB: Safe area renders inaccurately for UI{Navigation,TabBar}Controller. - // Fixes welcome! - if viewController is UINavigationController { - self.frame.size.height -= self.config.safeArea.top - self.config.safeArea.top = 0 - } else if let viewController = viewController as? UITabBarController { - self.frame.size.height -= self.config.safeArea.bottom - self.config.safeArea.bottom = 0 - if viewController.selectedViewController is UINavigationController { - self.frame.size.height -= self.config.safeArea.top - self.config.safeArea.top = 0 + private func getKeyWindow() -> UIWindow? { + var window: UIWindow? + if #available(iOS 13.0, *) { + window = UIApplication.sharedIfAvailable?.windows.first { $0.isKeyWindow } + } else { + window = UIApplication.sharedIfAvailable?.keyWindow } + return window } - self.isHidden = false - } - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + private final class Window: UIWindow { + var config: ViewImageConfig + + init(config: ViewImageConfig, viewController: UIViewController) { + let size = config.size ?? viewController.view.bounds.size + self.config = config + super.init(frame: .init(origin: .zero, size: size)) + + // NB: Safe area renders inaccurately for UI{Navigation,TabBar}Controller. + // Fixes welcome! + if viewController is UINavigationController { + self.frame.size.height -= self.config.safeArea.top + self.config.safeArea.top = 0 + } else if let viewController = viewController as? UITabBarController { + self.frame.size.height -= self.config.safeArea.bottom + self.config.safeArea.bottom = 0 + if viewController.selectedViewController is UINavigationController { + self.frame.size.height -= self.config.safeArea.top + self.config.safeArea.top = 0 + } + } + self.isHidden = false + } - @available(iOS 11.0, *) - override var safeAreaInsets: UIEdgeInsets { - #if os(iOS) - let removeTopInset = self.config.safeArea == .init(top: 20, left: 0, bottom: 0, right: 0) - && self.rootViewController?.prefersStatusBarHidden ?? false - if removeTopInset { return .zero } - #endif - return self.config.safeArea - } -} -#endif + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } -#if os(macOS) -import Cocoa + @available(iOS 11.0, *) + override var safeAreaInsets: UIEdgeInsets { + #if os(iOS) + let removeTopInset = + self.config.safeArea == .init(top: 20, left: 0, bottom: 0, right: 0) + && self.rootViewController?.prefersStatusBarHidden ?? false + if removeTopInset { return .zero } + #endif + return self.config.safeArea + } + } + #endif -private final class ScaledWindow: NSWindow { - override var backingScaleFactor: CGFloat { - return 2 - } -} -#endif + #if os(macOS) + import Cocoa + + private final class ScaledWindow: NSWindow { + override var backingScaleFactor: CGFloat { + return 2 + } + } + #endif #endif extension Array { diff --git a/Sources/SnapshotTesting/Common/XCTAttachment.swift b/Sources/SnapshotTesting/Common/XCTAttachment.swift index 8d5f17f64..b74c5688d 100644 --- a/Sources/SnapshotTesting/Common/XCTAttachment.swift +++ b/Sources/SnapshotTesting/Common/XCTAttachment.swift @@ -1,8 +1,8 @@ #if os(Linux) || os(Windows) -import Foundation + import Foundation -public struct XCTAttachment { - public init(data: Data) {} - public init(data: Data, uniformTypeIdentifier: String) {} -} + public struct XCTAttachment { + public init(data: Data) {} + public init(data: Data, uniformTypeIdentifier: String) {} + } #endif diff --git a/Sources/SnapshotTesting/Diff.swift b/Sources/SnapshotTesting/Diff.swift index 20d264cbf..be6f3c5a0 100644 --- a/Sources/SnapshotTesting/Diff.swift +++ b/Sources/SnapshotTesting/Diff.swift @@ -15,9 +15,11 @@ func diff(_ fst: [A], _ snd: [A]) -> [Difference] { var idxsOf = [A: [Int]]() fst.enumerated().forEach { idxsOf[$1, default: []].append($0) } - let sub = snd.enumerated().reduce((overlap: [Int: Int](), fst: 0, snd: 0, len: 0)) { sub, sndPair in + let sub = snd.enumerated().reduce((overlap: [Int: Int](), fst: 0, snd: 0, len: 0)) { + sub, sndPair in (idxsOf[sndPair.element] ?? []) - .reduce((overlap: [Int: Int](), fst: sub.fst, snd: sub.snd, len: sub.len)) { innerSub, fstIdx in + .reduce((overlap: [Int: Int](), fst: sub.fst, snd: sub.snd, len: sub.len)) { + innerSub, fstIdx in var newOverlap = innerSub.overlap newOverlap[fstIdx] = (sub.overlap[fstIdx - 1] ?? 0) + 1 @@ -26,7 +28,7 @@ func diff(_ fst: [A], _ snd: [A]) -> [Difference] { return (newOverlap, fstIdx - newLen + 1, sndPair.offset - newLen + 1, newLen) } return (newOverlap, innerSub.fst, innerSub.snd, innerSub.len) - } + } } let (_, fstIdx, sndIdx, len) = sub @@ -61,7 +63,7 @@ struct Hunk { // Semigroup - static func +(lhs: Hunk, rhs: Hunk) -> Hunk { + static func + (lhs: Hunk, rhs: Hunk) -> Hunk { return Hunk( fstIdx: lhs.fstIdx + rhs.fstIdx, fstLen: lhs.fstLen + rhs.fstLen, @@ -90,16 +92,20 @@ func chunk(diff diffs: [Difference], context ctx: Int = 4) -> [Hunk] { func prepending(_ prefix: String) -> (String) -> String { return { prefix + $0 + ($0.hasSuffix(" ") ? "¬" : "") } } - let changed: (Hunk) -> Bool = { $0.lines.contains(where: { $0.hasPrefix(minus) || $0.hasPrefix(plus) }) } + let changed: (Hunk) -> Bool = { + $0.lines.contains(where: { $0.hasPrefix(minus) || $0.hasPrefix(plus) }) + } - let (hunk, hunks) = diffs + let (hunk, hunks) = + diffs .reduce((current: Hunk(), hunks: [Hunk]())) { cursor, diff in let (current, hunks) = cursor let len = diff.elements.count switch diff.which { case .both where len > ctx * 2: - let hunk = current + Hunk(len: ctx, lines: diff.elements.prefix(ctx).map(prepending(figureSpace))) + let hunk = + current + Hunk(len: ctx, lines: diff.elements.prefix(ctx).map(prepending(figureSpace))) let next = Hunk( fstIdx: current.fstIdx + current.fstLen + len - ctx, fstLen: ctx, @@ -119,7 +125,7 @@ func chunk(diff diffs: [Difference], context ctx: Int = 4) -> [Hunk] { case .second: return (current + Hunk(sndLen: len, lines: diff.elements.map(prepending(plus))), hunks) } - } + } return changed(hunk) ? hunks + [hunk] : hunks } diff --git a/Sources/SnapshotTesting/Diffing.swift b/Sources/SnapshotTesting/Diffing.swift index 663271aeb..cf850b7b8 100644 --- a/Sources/SnapshotTesting/Diffing.swift +++ b/Sources/SnapshotTesting/Diffing.swift @@ -9,7 +9,8 @@ public struct Diffing { /// Produces a value _from_ data. public var fromData: (Data) -> Value - /// Compares two values. If the values do not match, returns a failure message and artifacts describing the failure. + /// Compares two values. If the values do not match, returns a failure message and artifacts + /// describing the failure. public var diff: (Value, Value) -> (String, [XCTAttachment])? /// Creates a new `Diffing` on `Value`. @@ -19,14 +20,15 @@ public struct Diffing { /// - value: A value to convert into data. /// - fromData: A function used to produce a value _from_ data. /// - data: Data to convert into a value. - /// - diff: A function used to compare two values. If the values do not match, returns a failure message and artifacts describing the failure. + /// - diff: A function used to compare two values. If the values do not match, returns a failure + /// message and artifacts describing the failure. /// - lhs: A value to compare. /// - rhs: Another value to compare. public init( toData: @escaping (_ value: Value) -> Data, fromData: @escaping (_ data: Data) -> Value, diff: @escaping (_ lhs: Value, _ rhs: Value) -> (String, [XCTAttachment])? - ) { + ) { self.toData = toData self.fromData = fromData self.diff = diff diff --git a/Sources/SnapshotTesting/Documentation.docc/AssertSnapshot.md b/Sources/SnapshotTesting/Documentation.docc/AssertSnapshot.md new file mode 100644 index 000000000..8b2bd4821 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/AssertSnapshot.md @@ -0,0 +1,19 @@ +# ``SnapshotTesting/assertSnapshot(of:as:named:record:timeout:file:testName:line:)`` + +## Topics + +### Multiple snapshots + +- ``assertSnapshots(of:as:record:timeout:file:testName:line:)-98vsq`` +- ``assertSnapshots(of:as:record:timeout:file:testName:line:)-4upll`` + +### Custom assertions + +- ``verifySnapshot(of:as:named:record:snapshotDirectory:timeout:file:testName:line:)`` + +### Deprecations + +- ``assertSnapshot(matching:as:named:record:timeout:file:testName:line:)`` +- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-3i804`` +- ``assertSnapshots(matching:as:record:timeout:file:testName:line:)-6bvvj`` +- ``verifySnapshot(matching:as:named:record:snapshotDirectory:timeout:file:testName:line:)`` diff --git a/Sources/SnapshotTesting/Documentation.docc/CustomStrategies.md b/Sources/SnapshotTesting/Documentation.docc/CustomStrategies.md new file mode 100644 index 000000000..7a698fd79 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/CustomStrategies.md @@ -0,0 +1,97 @@ +# Defining custom snapshot strategies + +While SnapshotTesting comes with a wide variety of snapshot strategies, it can also be extended with +custom, user-defined strategies using the ``SnapshotTesting/Snapshotting`` and +``SnapshotTesting/Diffing`` types. + +## Snapshotting + +The ``SnapshotTesting/Snapshotting`` type represents the ability to transform a snapshottable value +(like a view or data structure) into a diffable format (like an image or text). + +### Transforming existing strategies + +Existing strategies can be transformed to work with new types using the `pullback` method. + +For example, given the following `image` strategy on `UIView`: + +``` swift +Snapshotting.image +``` + +We can define an `image` strategy on `UIViewController` using the `pullback` method: + +``` swift +extension Snapshotting where Value == UIViewController, Format == UIImage { + public static let image: Snapshotting = Snapshotting + .image + .pullback { viewController in viewController.view } +} +``` + +Pullback takes a transform function from the new strategy's value to the existing strategy's value, +in this case `(UIViewController) -> UIView`. + +### Creating brand new strategies + +Most strategies can be built from existing ones, but if you've defined your own +``SnapshotTesting/Diffing`` strategy, you may need to create a base ``SnapshotTesting/Snapshotting`` +value alongside it. + +### Asynchronous Strategies + +Some types need to be snapshot in an asynchronous fashion. ``SnapshotTesting/Snapshotting`` offers +two APIs for building asynchronous strategies by utilizing a built-in ``Async`` type. + +#### Async pullbacks + +Alongside ``Snapshotting/pullback(_:)`` there is ``Snapshotting/asyncPullback(_:)``, which takes a +transform function `(NewStrategyValue) -> Async`. + +For example, WebKit's `WKWebView` offers a callback-based API for taking image snapshots, where the +image is passed asynchronously to the callback block. While `pullback` would require the `UIImage` +to be returned from the transform function, `asyncPullback` and `Async` allow us to pass the `image` +a value that can pass its callback along to the scope in which the image has been created. + +``` swift +extension Snapshotting where Value == WKWebView, Format == UIImage { + public static let image: Snapshotting = Snapshotting + .image + .asyncPullback { webView in + Async { callback in + webView.takeSnapshot(with: nil) { image, error in + callback(image!) + } + } + } +} +``` + +#### Async initialization + +`Snapshotting` defines an alternate initializer to describe snapshotting values in an asynchronous +fashion. + +For example, were we to define a strategy for `WKWebView` _without_ +``Snapshotting/asyncPullback(_:)``: + +``` swift +extension Snapshotting where Value == WKWebView, Format == UIImage { + public static let image = Snapshotting( + pathExtension: "png", + diffing: .image, + asyncSnapshot: { webView in + Async { callback in + webView.takeSnapshot(with: nil) { image, error in + callback(image!) + } + } + } + ) +} +``` + +## Diffing + +The ``SnapshotTesting/Diffing`` type represents the ability to compare `Value`s and convert them to +and from `Data`. diff --git a/Sources/SnapshotTesting/Documentation.docc/Deprecations.md b/Sources/SnapshotTesting/Documentation.docc/Deprecations.md new file mode 100644 index 000000000..f5460227c --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Deprecations.md @@ -0,0 +1,11 @@ +# Deprecations + +## Topics + +### Configuration + +- ``record`` + +### Supporting types + +- ``SnapshotTestCase`` diff --git a/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md b/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md new file mode 100644 index 000000000..92a11e84c --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md @@ -0,0 +1,25 @@ +# ``SnapshotTesting`` + +Powerfully flexible snapshot testing. + +## Topics + +### Essentials + +- ``assertSnapshot(of:as:named:record:timeout:file:testName:line:)`` + +### Strategies + +- +- ``Snapshotting`` +- ``Diffing`` +- ``Async`` + +### Configuration + +- ``isRecording`` +- ``diffTool`` + +### Deprecations + +- diff --git a/Sources/SnapshotTesting/Documentation.docc/Snapshotting.md b/Sources/SnapshotTesting/Documentation.docc/Snapshotting.md new file mode 100644 index 000000000..cf08d9f67 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Snapshotting.md @@ -0,0 +1,63 @@ +# ``SnapshotTesting/Snapshotting`` + +## Topics + +### Strategies + +- ``curl`` +- ``data`` +- ``description`` +- ``dump`` +- ``elementsDescription-20h0f`` +- ``elementsDescription-90719`` +- ``elementsDescription(numberFormatter:)-1g0wq`` +- ``elementsDescription(numberFormatter:)-8g5ik`` +- ``func(into:)`` +- ``image-34drn`` +- ``image-4k9c6`` +- ``image-4nvp5`` +- ``image-5ftbk`` +- ``image-8eey6`` +- ``image-8ng2q`` +- ``image(precision:perceptualPrecision:)-2gh0i`` +- ``image(precision:perceptualPrecision:)-2x9v4`` +- ``image(precision:perceptualPrecision:)-4o0qz`` +- ``image(precision:perceptualPrecision:size:)-1sccj`` +- ``image(precision:perceptualPrecision:size:)-20l7o`` +- ``image(precision:perceptualPrecision:size:)-7hqxj`` +- ``image(precision:perceptualPrecision:size:)-ba2u`` +- ``image(precision:perceptualPrecision:drawingMode:)`` +- ``json-745rx`` +- ``json-9cu20`` +- ``json(_:)`` +- ``lines`` +- ``plist`` +- ``plist(_:)`` +- ``raw`` +- ``raw(pretty:)`` +- ``recursiveDescription-227s4`` +- ``recursiveDescription-7anah`` + +### Defining a strategy + +- ``init(pathExtension:diffing:snapshot:)`` +- ``init(pathExtension:diffing:asyncSnapshot:)`` +- ``init(pathExtension:diffing:)`` + +### Transforming strategies + +- ``pullback(_:)`` +- ``asyncPullback(_:)`` +- ``wait(for:on:)`` + +### Properties + +- ``snapshot`` +- ``diffing`` +- ``pathExtension`` + +### Supporting types + +- ``AnySnapshotStringConvertible`` +- ``SimplySnapshotting`` +- ``SwiftUISnapshotLayout`` diff --git a/Sources/SnapshotTesting/Extensions/Wait.swift b/Sources/SnapshotTesting/Extensions/Wait.swift index 71316dff9..f15c7da19 100644 --- a/Sources/SnapshotTesting/Extensions/Wait.swift +++ b/Sources/SnapshotTesting/Extensions/Wait.swift @@ -2,15 +2,19 @@ import Foundation import XCTest extension Snapshotting { - /// Transforms an existing snapshot strategy into one that waits for some amount of time before taking the snapshot. This can be useful for waiting for animations to complete or for UIKit events to finish (i.e. waiting for a UINavigationController to push a child onto the stack). + /// Transforms an existing snapshot strategy into one that waits for some amount of time before + /// taking the snapshot. This can be useful for waiting for animations to complete or for UIKit + /// events to finish (_i.e._ waiting for a `UINavigationController` to push a child onto the + /// stack). + /// /// - Parameters: /// - duration: The amount of time to wait before taking the snapshot. /// - strategy: The snapshot to invoke after the specified amount of time has passed. public static func wait( for duration: TimeInterval, - on strategy: Snapshotting - ) -> Snapshotting { - return Snapshotting( + on strategy: Self + ) -> Self { + Self( pathExtension: strategy.pathExtension, diffing: strategy.diffing, asyncSnapshot: { value in @@ -22,6 +26,6 @@ extension Snapshotting { _ = XCTWaiter.wait(for: [expectation], timeout: duration + 1) strategy.snapshot(value).run(callback) } - }) + }) } } diff --git a/Sources/SnapshotTesting/Internal/Deprecations.swift b/Sources/SnapshotTesting/Internal/Deprecations.swift index 9b41ff5ec..f3512d977 100644 --- a/Sources/SnapshotTesting/Internal/Deprecations.swift +++ b/Sources/SnapshotTesting/Internal/Deprecations.swift @@ -1,6 +1,325 @@ import Foundation import XCTest +// Deprecated after 1.12.0: + +@available( + *, + deprecated, + message: """ + Use 'assertInlineSnapshot(of:)' from the 'InlineSnapshotTesting' module, instead. + """ +) +public func _assertInlineSnapshot( + matching value: @autoclosure () throws -> Value, + as snapshotting: Snapshotting, + record recording: Bool = false, + timeout: TimeInterval = 5, + with reference: String, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line +) { + + let failure = _verifyInlineSnapshot( + matching: try value(), + as: snapshotting, + record: recording, + timeout: timeout, + with: reference, + file: file, + testName: testName, + line: line + ) + guard let message = failure else { return } + XCTFail(message, file: file, line: line) +} + +@available( + *, + deprecated, + message: """ + Use 'assertInlineSnapshot(of:)' from the 'InlineSnapshotTesting' module, instead. + """ +) +public func _verifyInlineSnapshot( + matching value: @autoclosure () throws -> Value, + as snapshotting: Snapshotting, + record recording: Bool = false, + timeout: TimeInterval = 5, + with reference: String, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line +) + -> String? +{ + + let recording = recording || isRecording + + do { + let tookSnapshot = XCTestExpectation(description: "Took snapshot") + var optionalDiffable: String? + snapshotting.snapshot(try value()).run { b in + optionalDiffable = b + tookSnapshot.fulfill() + } + let result = XCTWaiter.wait(for: [tookSnapshot], timeout: timeout) + switch result { + case .completed: + break + case .timedOut: + return """ + Exceeded timeout of \(timeout) seconds waiting for snapshot. + + This can happen when an asynchronously rendered view (like a web view) has not loaded. \ + Ensure that every subview of the view hierarchy has loaded to avoid timeouts, or, if a \ + timeout is unavoidable, consider setting the "timeout" parameter of "assertSnapshot" to \ + a higher value. + """ + case .incorrectOrder, .invertedFulfillment, .interrupted: + return "Couldn't snapshot value" + @unknown default: + return "Couldn't snapshot value" + } + + let trimmingChars = CharacterSet.whitespacesAndNewlines.union( + CharacterSet(charactersIn: "\u{FEFF}")) + guard let diffable = optionalDiffable?.trimmingCharacters(in: trimmingChars) else { + return "Couldn't snapshot value" + } + + let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines) + + // Always perform diff, and return early on success! + guard let (failure, attachments) = snapshotting.diffing.diff(trimmedReference, diffable) else { + return nil + } + + // If that diff failed, we either record or fail. + if recording || trimmedReference.isEmpty { + let fileName = "\(file)" + let sourceCodeFilePath = URL(fileURLWithPath: fileName, isDirectory: false) + let sourceCode = try String(contentsOf: sourceCodeFilePath) + var newRecordings = recordings + + let modifiedSource = try writeInlineSnapshot( + &newRecordings, + Context( + sourceCode: sourceCode, + diffable: diffable, + fileName: fileName, + lineIndex: Int(line) + ) + ).sourceCode + + try modifiedSource + .data(using: String.Encoding.utf8)? + .write(to: sourceCodeFilePath) + + if newRecordings != recordings { + recordings = newRecordings + /// If no other recording has been made, then fail! + return """ + No reference was found inline. Automatically recorded snapshot. + + Re-run "\(sanitizePathComponent(testName))" to test against the newly-recorded snapshot. + """ + } else { + /// There is already an failure in this file, + /// and we don't want to write to the wrong place. + return nil + } + } + + /// Did not successfully record, so we will fail. + if !attachments.isEmpty { + #if !os(Linux) && !os(Windows) + if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") { + XCTContext.runActivity(named: "Attached Failure Diff") { activity in + attachments.forEach { + activity.add($0) + } + } + } + #endif + } + + return """ + Snapshot does not match reference. + + \(failure.trimmingCharacters(in: .whitespacesAndNewlines)) + """ + + } catch { + return error.localizedDescription + } +} + +private typealias Recordings = [String: [FileRecording]] + +private struct Context { + let sourceCode: String + let diffable: String + let fileName: String + // First line of a file is line 1 (as with the #line macro) + let lineIndex: Int + + func setSourceCode(_ newSourceCode: String) -> Context { + return Context( + sourceCode: newSourceCode, + diffable: diffable, + fileName: fileName, + lineIndex: lineIndex + ) + } +} + +private func writeInlineSnapshot( + _ recordings: inout Recordings, + _ context: Context +) throws -> Context { + var sourceCodeLines = context.sourceCode + .split(separator: "\n", omittingEmptySubsequences: false) + + let otherRecordings = recordings[context.fileName, default: []] + let otherRecordingsAboveThisLine = otherRecordings.filter { $0.line < context.lineIndex } + let offsetStartIndex = otherRecordingsAboveThisLine.reduce(context.lineIndex) { + $0 + $1.difference + } + let functionLineIndex = offsetStartIndex - 1 + var lineCountDifference = 0 + + // Convert `""` to multi-line literal + if sourceCodeLines[functionLineIndex].hasSuffix(emptyStringLiteralWithCloseBrace) { + // Convert: + // _assertInlineSnapshot(matching: value, as: .dump, with: "") + // to: + // _assertInlineSnapshot(matching: value, as: .dump, with: """ + // """) + var functionCallLine = sourceCodeLines.remove(at: functionLineIndex) + functionCallLine.removeLast(emptyStringLiteralWithCloseBrace.count) + let indentText = indentation(of: functionCallLine) + sourceCodeLines.insert( + contentsOf: [ + functionCallLine + multiLineStringLiteralTerminator, + indentText + multiLineStringLiteralTerminator + ")", + ] as [String.SubSequence], at: functionLineIndex) + lineCountDifference += 1 + } + + /// If they haven't got a multi-line literal by now, then just fail. + guard sourceCodeLines[functionLineIndex].hasSuffix(multiLineStringLiteralTerminator) else { + struct InlineError: LocalizedError { + var errorDescription: String? { + return """ + To use inline snapshots, please convert the "with" argument to a multi-line literal. + """ + } + } + throw InlineError() + } + + /// Find the end of multi-line literal and replace contents with recording. + if let multiLineLiteralEndIndex = sourceCodeLines[offsetStartIndex...].firstIndex(where: { + $0.hasClosingMultilineStringDelimiter() + }) { + + let diffableLines = context.diffable.split(separator: "\n") + + // Add #'s to the multiline string literal if needed + let numberSigns: String + if context.diffable.hasEscapedSpecialCharactersLiteral() { + numberSigns = String(repeating: "#", count: context.diffable.numberOfNumberSignsNeeded()) + } else if nil != diffableLines.first(where: { $0.endsInBackslash() }) { + // We want to avoid \ being interpreted as an escaped newline in the recorded inline snapshot + numberSigns = "#" + } else { + numberSigns = "" + } + let multiLineStringLiteralTerminatorPre = numberSigns + multiLineStringLiteralTerminator + let multiLineStringLiteralTerminatorPost = multiLineStringLiteralTerminator + numberSigns + + // Update opening (#...)""" + sourceCodeLines[functionLineIndex].replaceFirstOccurrence( + of: extendedOpeningStringDelimitersPattern, + with: multiLineStringLiteralTerminatorPre + ) + + // Update closing """(#...) + sourceCodeLines[multiLineLiteralEndIndex].replaceFirstOccurrence( + of: extendedClosingStringDelimitersPattern, + with: multiLineStringLiteralTerminatorPost + ) + + /// Convert actual value to Lines to insert + let indentText = indentation(of: sourceCodeLines[multiLineLiteralEndIndex]) + let newDiffableLines = context.diffable + .split(separator: "\n", omittingEmptySubsequences: false) + .map { Substring(indentText + $0) } + lineCountDifference += newDiffableLines.count - (multiLineLiteralEndIndex - offsetStartIndex) + + let fileRecording = FileRecording(line: context.lineIndex, difference: lineCountDifference) + + /// Insert the lines + sourceCodeLines.replaceSubrange( + offsetStartIndex..(of str: S) -> String { + var count = 0 + for char in str { + guard char == " " else { break } + count += 1 + } + return String(repeating: " ", count: count) +} + +extension Substring { + fileprivate mutating func replaceFirstOccurrence(of pattern: String, with newString: String) { + let newString = replacingOccurrences(of: pattern, with: newString, options: .regularExpression) + self = Substring(newString) + } + + fileprivate func hasOpeningMultilineStringDelimiter() -> Bool { + return range(of: extendedOpeningStringDelimitersPattern, options: .regularExpression) != nil + } + + fileprivate func hasClosingMultilineStringDelimiter() -> Bool { + return range(of: extendedClosingStringDelimitersPattern, options: .regularExpression) != nil + } + + fileprivate func endsInBackslash() -> Bool { + if let lastChar = last { + return lastChar == Character(#"\"#) + } + return false + } +} + +private let emptyStringLiteralWithCloseBrace = "\"\")" +private let multiLineStringLiteralTerminator = "\"\"\"" +private let extendedOpeningStringDelimitersPattern = #"#{0,}\"\"\""# +private let extendedClosingStringDelimitersPattern = ##"\"\"\"#{0,}"## + +// When we modify a file, the line numbers reported by the compiler through #line are no longer +// accurate. With the FileRecording values we keep track of we modify the files so we can adjust +// line numbers. +private var recordings: Recordings = [:] + +// Deprecated after 1.11.1: + @available(iOS, deprecated: 10000, message: "Use `assertSnapshot(of:…:)` instead.") @available(macOS, deprecated: 10000, message: "Use `assertSnapshot(of:…:)` instead.") @available(tvOS, deprecated: 10000, message: "Use `assertSnapshot(of:…:)` instead.") @@ -102,3 +421,6 @@ public func verifySnapshot( line: line ) } + +@available(*, deprecated, renamed: "XCTestCase") +public typealias SnapshotTestCase = XCTestCase diff --git a/Sources/SnapshotTesting/SnapshotTestCase.swift b/Sources/SnapshotTesting/SnapshotTestCase.swift deleted file mode 100644 index ba9375260..000000000 --- a/Sources/SnapshotTesting/SnapshotTestCase.swift +++ /dev/null @@ -1,4 +0,0 @@ -import XCTest - -@available(swift, obsoleted: 5.0, renamed: "XCTestCase", message: "Please use XCTestCase instead") -public typealias SnapshotTestCase = XCTestCase diff --git a/Sources/SnapshotTesting/Snapshotting.swift b/Sources/SnapshotTesting/Snapshotting.swift index b58e2016a..02c655409 100644 --- a/Sources/SnapshotTesting/Snapshotting.swift +++ b/Sources/SnapshotTesting/Snapshotting.swift @@ -1,7 +1,8 @@ import Foundation import XCTest -/// A type representing the ability to transform a snapshottable value into a diffable format (like text or an image) for snapshot testing. +/// A type representing the ability to transform a snapshottable value into a diffable format (like +/// text or an image) for snapshot testing. public struct Snapshotting { /// The path extension applied to references saved to disk. public var pathExtension: String? @@ -17,13 +18,14 @@ public struct Snapshotting { /// - Parameters: /// - pathExtension: The path extension applied to references saved to disk. /// - diffing: How to diff and convert the snapshot format to and from data. - /// - asyncSnapshot: An asynchronous transform function from a value into a diffable snapshot format. + /// - asyncSnapshot: An asynchronous transform function from a value into a diffable snapshot + /// format. /// - value: A value to be converted. public init( pathExtension: String?, diffing: Diffing, asyncSnapshot: @escaping (_ value: Value) -> Async - ) { + ) { self.pathExtension = pathExtension self.diffing = diffing self.snapshot = asyncSnapshot @@ -40,55 +42,73 @@ public struct Snapshotting { pathExtension: String?, diffing: Diffing, snapshot: @escaping (_ value: Value) -> Format - ) { + ) { self.init(pathExtension: pathExtension, diffing: diffing) { Async(value: snapshot($0)) } } - /// Transforms a strategy on `Value`s into a strategy on `NewValue`s through a function `(NewValue) -> Value`. + /// Transforms a strategy on `Value`s into a strategy on `NewValue`s through a function + /// `(NewValue) -> Value`. /// - /// This is the most important operation for transforming existing strategies into new strategies. It allows you to transform a `Snapshotting` into a `Snapshotting` by pulling it back along a function `(NewValue) -> Value`. Notice that the function must go in the direction `(NewValue) -> Value` even though we are transforming in the other direction `(Snapshotting) -> Snapshotting`. + /// This is the most important operation for transforming existing strategies into new strategies. + /// It allows you to transform a `Snapshotting` into a + /// `Snapshotting` by pulling it back along a function `(NewValue) -> Value`. + /// Notice that the function must go in the direction `(NewValue) -> Value` even though we are + /// transforming in the other direction + /// `(Snapshotting) -> Snapshotting`. /// - /// A simple example of this is to `pullback` the snapshot strategy on `UIView`s to work on `UIViewController`s: + /// A simple example of this is to `pullback` the snapshot strategy on `UIView`s to work on + /// `UIViewController`s: /// - /// let strategy = Snapshotting.image.pullback { (vc: UIViewController) in - /// return vc.view - /// } + /// ```swift + /// let strategy = Snapshotting.image.pullback { (vc: UIViewController) in + /// vc.view + /// } + /// ``` /// - /// Here we took the strategy that snapshots `UIView`s as `UIImage`s and pulled it back to work on `UIViewController`s by using the function `(UIViewController) -> UIView` that simply plucks the view out of the controller. + /// Here we took the strategy that snapshots `UIView`s as `UIImage`s and pulled it back to work on + /// `UIViewController`s by using the function `(UIViewController) -> UIView` that simply plucks + /// the view out of the controller. /// - /// Nearly every snapshot strategy provided in this library is a pullback of some base strategy, which shows just how important this operation is. + /// Nearly every snapshot strategy provided in this library is a pullback of some base strategy, + /// which shows just how important this operation is. /// /// - Parameters: /// - transform: A transform function from `NewValue` into `Value`. /// - otherValue: A value to be transformed. - public func pullback(_ transform: @escaping (_ otherValue: NewValue) -> Value) -> Snapshotting { - return self.asyncPullback { newValue in Async(value: transform(newValue)) } + public func pullback(_ transform: @escaping (_ otherValue: NewValue) -> Value) + -> Snapshotting + { + self.asyncPullback { newValue in Async(value: transform(newValue)) } } - /// Transforms a strategy on `Value`s into a strategy on `NewValue`s through a function `(NewValue) -> Async`. + /// Transforms a strategy on `Value`s into a strategy on `NewValue`s through a function + /// `(NewValue) -> Async`. /// - /// See the documentation of `pullback` for a full description of how pullbacks works. This operation differs from `pullback` in that it allows you to use a transformation `(NewValue) -> Async`, which is necessary when your transformation needs to perform some asynchronous work. + /// See the documentation of `pullback` for a full description of how pullbacks works. This + /// operation differs from `pullback` in that it allows you to use a transformation + /// `(NewValue) -> Async`, which is necessary when your transformation needs to perform + /// some asynchronous work. /// /// - Parameters: /// - transform: A transform function from `NewValue` into `Async`. /// - otherValue: A value to be transformed. - public func asyncPullback(_ transform: @escaping (_ otherValue: NewValue) -> Async) - -> Snapshotting { - - return Snapshotting( - pathExtension: self.pathExtension, - diffing: self.diffing - ) { newValue in - return .init { callback in - transform(newValue).run { value in - self.snapshot(value).run { snapshot in - callback(snapshot) - } + public func asyncPullback( + _ transform: @escaping (_ otherValue: NewValue) -> Async + ) -> Snapshotting { + Snapshotting( + pathExtension: self.pathExtension, + diffing: self.diffing + ) { newValue in + .init { callback in + transform(newValue).run { value in + self.snapshot(value).run { snapshot in + callback(snapshot) } } } + } } } diff --git a/Sources/SnapshotTesting/Snapshotting/Any.swift b/Sources/SnapshotTesting/Snapshotting/Any.swift index 385c2642d..660def671 100644 --- a/Sources/SnapshotTesting/Snapshotting/Any.swift +++ b/Sources/SnapshotTesting/Snapshotting/Any.swift @@ -1,7 +1,46 @@ import Foundation +extension Snapshotting where Format == String { + /// A snapshot strategy that captures a value's textual description from `String`'s + /// `init(describing:)` initializer. + /// + /// ``` swift + /// assertSnapshot(of: user, as: .description) + /// ``` + /// + /// Records: + /// + /// ``` + /// User(bio: "Blobbed around the world.", id: 1, name: "Blobby") + /// ``` + public static var description: Snapshotting { + return SimplySnapshotting.lines.pullback(String.init(describing:)) + } +} + extension Snapshotting where Format == String { /// A snapshot strategy for comparing any structure based on a sanitized text dump. + /// + /// The reference format looks a lot like the output of Swift's built-in `dump` function, though + /// it does its best to make output deterministic by stripping out pointer memory addresses and + /// sorting non-deterministic data, like dictionaries and sets. + /// + /// You can hook into how an instance of a type is rendered in this strategy by conforming to the + /// ``AnySnapshotStringConvertible`` protocol and defining the + /// ``AnySnapshotStringConvertible/snapshotDescription` property. + /// + /// ```swift + /// assertSnapshot(of: user, as: .dump) + /// ``` + /// + /// Records: + /// + /// ``` + /// ▿ User + /// - bio: "Blobbed around the world." + /// - id: 1 + /// - name: "Blobby" + /// ``` public static var dump: Snapshotting { return SimplySnapshotting.lines.pullback { snap($0) } } @@ -13,12 +52,14 @@ extension Snapshotting where Format == String { public static var json: Snapshotting { let options: JSONSerialization.WritingOptions = [ .prettyPrinted, - .sortedKeys + .sortedKeys, ] var snapshotting = SimplySnapshotting.lines.pullback { (data: Value) in - try! String(decoding: JSONSerialization.data(withJSONObject: data, - options: options), as: UTF8.self) + try! String( + decoding: JSONSerialization.data( + withJSONObject: data, + options: options), as: UTF8.self) } snapshotting.pathExtension = "json" return snapshotting @@ -66,7 +107,8 @@ private func snap(_ value: T, name: String? = nil, indent: Int = 0) -> String description = String(describing: value) } - let lines = ["\(indentation)\(bullet) \(name.map { "\($0): " } ?? "")\(description)\n"] + let lines = + ["\(indentation)\(bullet) \(name.map { "\($0): " } ?? "")\(description)\n"] + children.map { snap($1, name: $0, indent: indent + 2) } return lines.joined() @@ -83,7 +125,8 @@ private func sort(_ children: Mirror.Children) -> Mirror.Children { /// A type with a customized snapshot dump representation. /// -/// Types that conform to the `AnySnapshotStringConvertible` protocol can provide their own representation to be used when converting an instance to a `dump`-based snapshot. +/// Types that conform to the `AnySnapshotStringConvertible` protocol can provide their own +/// representation to be used when converting an instance to a `dump`-based snapshot. public protocol AnySnapshotStringConvertible { /// Whether or not to dump child nodes (defaults to `false`). static var renderChildren: Bool { get } @@ -118,13 +161,13 @@ extension Date: AnySnapshotStringConvertible { extension NSObject: AnySnapshotStringConvertible { #if canImport(ObjectiveC) - @objc open var snapshotDescription: String { - return purgePointers(self.debugDescription) - } + @objc open var snapshotDescription: String { + return purgePointers(self.debugDescription) + } #else - open var snapshotDescription: String { - return purgePointers(self.debugDescription) - } + open var snapshotDescription: String { + return purgePointers(self.debugDescription) + } #endif } @@ -156,5 +199,6 @@ private let snapshotDateFormatter: DateFormatter = { }() func purgePointers(_ string: String) -> String { - return string.replacingOccurrences(of: ":?\\s*0x[\\da-f]+(\\s*)", with: "$1", options: .regularExpression) + return string.replacingOccurrences( + of: ":?\\s*0x[\\da-f]+(\\s*)", with: "$1", options: .regularExpression) } diff --git a/Sources/SnapshotTesting/Snapshotting/CALayer.swift b/Sources/SnapshotTesting/Snapshotting/CALayer.swift index 365e0ffb2..74c512c12 100644 --- a/Sources/SnapshotTesting/Snapshotting/CALayer.swift +++ b/Sources/SnapshotTesting/Snapshotting/CALayer.swift @@ -1,56 +1,77 @@ #if os(macOS) -import AppKit -import Cocoa -import QuartzCore + import AppKit + import Cocoa + import QuartzCore -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) - } + extension Snapshotting where Value == CALayer, Format == NSImage { + /// A snapshot strategy for comparing layers based on pixel equality. + /// + /// ``` swift + /// // Match reference perfectly. + /// assertSnapshot(of: layer, as: .image) + /// + /// // Allow for a 1% pixel difference. + /// assertSnapshot(of: layer, as: .image(precision: 0.99)) + /// ``` + public static var image: Snapshotting { + return .image(precision: 1) + } - /// A snapshot strategy for comparing layers based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - public static func image(precision: Float, perceptualPrecision: Float = 1) -> Snapshotting { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { layer in - let image = NSImage(size: layer.bounds.size) - image.lockFocus() - let context = NSGraphicsContext.current!.cgContext - layer.setNeedsLayout() - layer.layoutIfNeeded() - layer.render(in: context) - image.unlockFocus() - return image + /// A snapshot strategy for comparing layers based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + public static func image(precision: Float, perceptualPrecision: Float = 1) -> Snapshotting { + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision + ).pullback { layer in + let image = NSImage(size: layer.bounds.size) + image.lockFocus() + let context = NSGraphicsContext.current!.cgContext + layer.setNeedsLayout() + layer.layoutIfNeeded() + layer.render(in: context) + image.unlockFocus() + return image + } } } -} #elseif os(iOS) || os(tvOS) -import UIKit + import UIKit -extension Snapshotting where Value == CALayer, Format == UIImage { - /// A snapshot strategy for comparing layers based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == CALayer, Format == UIImage { + /// A snapshot strategy for comparing layers based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing layers based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - traits: A trait collection override. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init()) - -> Snapshotting { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale).pullback { layer in + /// A snapshot strategy for comparing layers based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - traits: A trait collection override. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init() + ) + -> Snapshotting + { + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + ).pullback { layer in renderer(bounds: layer.bounds, for: traits).image { ctx in layer.setNeedsLayout() layer.layoutIfNeeded() layer.render(in: ctx.cgContext) } } + } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/CGPath.swift b/Sources/SnapshotTesting/Snapshotting/CGPath.swift index 5c32f0606..1e8c30444 100644 --- a/Sources/SnapshotTesting/Snapshotting/CGPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/CGPath.swift @@ -1,132 +1,157 @@ #if os(macOS) -import AppKit -import Cocoa -import CoreGraphics - -extension Snapshotting where Value == CGPath, Format == NSImage { - /// A snapshot strategy for comparing bezier paths based on pixel equality. - public static var image: Snapshotting { - return .image() - } + import AppKit + import Cocoa + import CoreGraphics + + extension Snapshotting where Value == CGPath, Format == NSImage { + /// A snapshot strategy for comparing bezier paths based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing bezier paths based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { path in - let bounds = path.boundingBoxOfPath - var transform = CGAffineTransform(translationX: -bounds.origin.x, y: -bounds.origin.y) - let path = path.copy(using: &transform)! - - let image = NSImage(size: bounds.size) - image.lockFocus() - let context = NSGraphicsContext.current!.cgContext - - context.addPath(path) - context.drawPath(using: drawingMode) - image.unlockFocus() - return image + /// A snapshot strategy for comparing bezier paths based on pixel equality. + /// + /// ``` swift + /// // Match reference perfectly. + /// assertSnapshot(of: path, as: .image) + /// + /// // Allow for a 1% pixel difference. + /// assertSnapshot(of: path, as: .image(precision: 0.99)) + /// ``` + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, drawingMode: CGPathDrawingMode = .eoFill + ) -> Snapshotting { + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision + ).pullback { path in + let bounds = path.boundingBoxOfPath + var transform = CGAffineTransform(translationX: -bounds.origin.x, y: -bounds.origin.y) + let path = path.copy(using: &transform)! + + let image = NSImage(size: bounds.size) + image.lockFocus() + let context = NSGraphicsContext.current!.cgContext + + context.addPath(path) + context.drawPath(using: drawingMode) + image.unlockFocus() + return image + } } } -} #elseif os(iOS) || os(tvOS) -import UIKit + import UIKit -extension Snapshotting where Value == CGPath, Format == UIImage { - /// A snapshot strategy for comparing bezier paths based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == CGPath, Format == UIImage { + /// A snapshot strategy for comparing bezier paths based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing bezier paths based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: scale).pullback { path in - let bounds = path.boundingBoxOfPath - let format: UIGraphicsImageRendererFormat - if #available(iOS 11.0, tvOS 11.0, *) { - format = UIGraphicsImageRendererFormat.preferred() - } else { - format = UIGraphicsImageRendererFormat.default() - } - format.scale = scale - return UIGraphicsImageRenderer(bounds: bounds, format: format).image { ctx in - let cgContext = ctx.cgContext - cgContext.addPath(path) - cgContext.drawPath(using: drawingMode) + /// A snapshot strategy for comparing bezier paths based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1, + drawingMode: CGPathDrawingMode = .eoFill + ) -> Snapshotting { + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: scale + ).pullback { path in + let bounds = path.boundingBoxOfPath + let format: UIGraphicsImageRendererFormat + if #available(iOS 11.0, tvOS 11.0, *) { + format = UIGraphicsImageRendererFormat.preferred() + } else { + format = UIGraphicsImageRendererFormat.default() + } + format.scale = scale + return UIGraphicsImageRenderer(bounds: bounds, format: format).image { ctx in + let cgContext = ctx.cgContext + cgContext.addPath(path) + cgContext.drawPath(using: drawingMode) + } } } } -} #endif #if os(macOS) || os(iOS) || os(tvOS) -@available(iOS 11.0, OSX 10.13, tvOS 11.0, *) -extension Snapshotting where Value == CGPath, Format == String { - /// A snapshot strategy for comparing bezier paths based on element descriptions. - public static var elementsDescription: Snapshotting { - return .elementsDescription(numberFormatter: defaultNumberFormatter) - } - - /// A snapshot strategy for comparing bezier paths based on element descriptions. - /// - /// - Parameter numberFormatter: The number formatter used for formatting points. - public static func elementsDescription(numberFormatter: NumberFormatter) -> Snapshotting { - let namesByType: [CGPathElementType: String] = [ - .moveToPoint: "MoveTo", - .addLineToPoint: "LineTo", - .addQuadCurveToPoint: "QuadCurveTo", - .addCurveToPoint: "CurveTo", - .closeSubpath: "Close", - ] - - let numberOfPointsByType: [CGPathElementType: Int] = [ - .moveToPoint: 1, - .addLineToPoint: 1, - .addQuadCurveToPoint: 2, - .addCurveToPoint: 3, - .closeSubpath: 0, - ] - - return SimplySnapshotting.lines.pullback { path in - var string: String = "" - - path.applyWithBlock { elementPointer in - let element = elementPointer.pointee - let name = namesByType[element.type] ?? "Unknown" - - if element.type == .moveToPoint && !string.isEmpty { - string += "\n" - } + @available(iOS 11.0, OSX 10.13, tvOS 11.0, *) + extension Snapshotting where Value == CGPath, Format == String { + /// A snapshot strategy for comparing bezier paths based on element descriptions. + public static var elementsDescription: Snapshotting { + .elementsDescription(numberFormatter: defaultNumberFormatter) + } - string += name + /// A snapshot strategy for comparing bezier paths based on element descriptions. + /// + /// - Parameter numberFormatter: The number formatter used for formatting points. + public static func elementsDescription(numberFormatter: NumberFormatter) -> Snapshotting { + let namesByType: [CGPathElementType: String] = [ + .moveToPoint: "MoveTo", + .addLineToPoint: "LineTo", + .addQuadCurveToPoint: "QuadCurveTo", + .addCurveToPoint: "CurveTo", + .closeSubpath: "Close", + ] + + let numberOfPointsByType: [CGPathElementType: Int] = [ + .moveToPoint: 1, + .addLineToPoint: 1, + .addQuadCurveToPoint: 2, + .addCurveToPoint: 3, + .closeSubpath: 0, + ] + + return SimplySnapshotting.lines.pullback { path in + var string: String = "" + + path.applyWithBlock { elementPointer in + let element = elementPointer.pointee + let name = namesByType[element.type] ?? "Unknown" + + if element.type == .moveToPoint && !string.isEmpty { + string += "\n" + } + + string += name + + if let numberOfPoints = numberOfPointsByType[element.type] { + let points = UnsafeBufferPointer(start: element.points, count: numberOfPoints) + string += + " " + + points.map { point in + let x = numberFormatter.string(from: point.x as NSNumber)! + let y = numberFormatter.string(from: point.y as NSNumber)! + return "(\(x), \(y))" + }.joined(separator: " ") + } - if let numberOfPoints = numberOfPointsByType[element.type] { - let points = UnsafeBufferPointer(start: element.points, count: numberOfPoints) - string += " " + points.map { point in - let x = numberFormatter.string(from: point.x as NSNumber)! - let y = numberFormatter.string(from: point.y as NSNumber)! - return "(\(x), \(y))" - }.joined(separator: " ") + string += "\n" } - string += "\n" + return string } - - return string } } -} - -private let defaultNumberFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.minimumFractionDigits = 1 - numberFormatter.maximumFractionDigits = 3 - return numberFormatter -}() + + private let defaultNumberFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.minimumFractionDigits = 1 + numberFormatter.maximumFractionDigits = 3 + return numberFormatter + }() #endif diff --git a/Sources/SnapshotTesting/Snapshotting/CaseIterable.swift b/Sources/SnapshotTesting/Snapshotting/CaseIterable.swift index 1a6330e07..fcdcaab7d 100644 --- a/Sources/SnapshotTesting/Snapshotting/CaseIterable.swift +++ b/Sources/SnapshotTesting/Snapshotting/CaseIterable.swift @@ -1,20 +1,50 @@ -extension Snapshotting where Value: CaseIterable, Format == String { - /// A strategy for snapshotting the output for every input of a function. The format of the snapshot - /// is a comma-separated value (CSV) file that shows the mapping of inputs to outputs. +extension Snapshotting where Value: CaseIterable, Format == String { + /// A strategy for snapshotting the output for every input of a function. The format of the + /// snapshot is a comma-separated value (CSV) file that shows the mapping of inputs to outputs. /// - /// Parameter witness: A snapshotting value on the output of the function to be snapshot. - /// Returns: A snapshot strategy on functions (Value) -> A that feeds every possible input into the - /// function and records the output into a CSV file. - public static func `func`(into witness: Snapshotting) -> Snapshotting<(Value) -> A, Format> { + /// - Parameter witness: A snapshotting value on the output of the function to be snapshot. + /// - Returns: A snapshot strategy on functions `(Value) -> A` that feeds every possible input + /// into the function and records the output into a CSV file. + /// + /// ```swift + /// enum Direction: String, CaseIterable { + /// case up, down, left, right + /// var rotatedLeft: Direction { + /// switch self { + /// case .up: return .left + /// case .down: return .right + /// case .left: return .down + /// case .right: return .up + /// } + /// } + /// } + /// + /// assertSnapshot( + /// of: \Direction.rotatedLeft, + /// as: .func(into: .description) + /// ) + /// ``` + /// + /// Records: + /// + /// ```csv + /// "up","left" + /// "down","right" + /// "left","down" + /// "right","up" + /// ``` + public static func `func`(into witness: Snapshotting) -> Snapshotting< + (Value) -> A, Format + > { var snapshotting = Snapshotting.lines.asyncPullback { (f: (Value) -> A) in Value.allCases.map { input in witness.snapshot(f(input)) .map { (input, $0) } - } - .sequence() - .map { rows in - rows.map { "\"\($0)\",\"\($1)\"" } - .joined(separator: "\n") + } + .sequence() + .map { rows in + rows.map { "\"\($0)\",\"\($1)\"" } + .joined(separator: "\n") } } diff --git a/Sources/SnapshotTesting/Snapshotting/Data.swift b/Sources/SnapshotTesting/Snapshotting/Data.swift index d28778f06..9d2eb467a 100644 --- a/Sources/SnapshotTesting/Snapshotting/Data.swift +++ b/Sources/SnapshotTesting/Snapshotting/Data.swift @@ -2,12 +2,14 @@ import Foundation import XCTest extension Snapshotting where Value == Data, Format == Data { + /// A snapshot strategy for comparing bare binary data. public static var data: Snapshotting { return .init( pathExtension: nil, diffing: .init(toData: { $0 }, fromData: { $0 }) { old, new in guard old != new else { return nil } - let message = old.count == new.count + let message = + old.count == new.count ? "Expected data to match" : "Expected \(new) to match \(old)" return (message, []) diff --git a/Sources/SnapshotTesting/Snapshotting/Description.swift b/Sources/SnapshotTesting/Snapshotting/Description.swift deleted file mode 100644 index ce9eb6784..000000000 --- a/Sources/SnapshotTesting/Snapshotting/Description.swift +++ /dev/null @@ -1,7 +0,0 @@ -extension Snapshotting where Format == String { - /// A snapshot strategy that captures a value's textual description from `String`'s `init(describing:)` - /// initializer. - public static var description: Snapshotting { - return SimplySnapshotting.lines.pullback(String.init(describing:)) - } -} diff --git a/Sources/SnapshotTesting/Snapshotting/Codable.swift b/Sources/SnapshotTesting/Snapshotting/Encodable.swift similarity index 63% rename from Sources/SnapshotTesting/Snapshotting/Codable.swift rename to Sources/SnapshotTesting/Snapshotting/Encodable.swift index 7f611a34b..441679c56 100644 --- a/Sources/SnapshotTesting/Snapshotting/Codable.swift +++ b/Sources/SnapshotTesting/Snapshotting/Encodable.swift @@ -2,6 +2,20 @@ import Foundation extension Snapshotting where Value: Encodable, Format == String { /// A snapshot strategy for comparing encodable structures based on their JSON representation. + /// + /// ```swift + /// assertSnapshot(of: user, as: .json) + /// ``` + /// + /// Records: + /// + /// ```json + /// { + /// "bio" : "Blobbed around the world.", + /// "id" : 1, + /// "name" : "Blobby" + /// } + /// ``` @available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, *) public static var json: Snapshotting { let encoder = JSONEncoder() @@ -20,14 +34,38 @@ extension Snapshotting where Value: Encodable, Format == String { return snapshotting } - /// A snapshot strategy for comparing encodable structures based on their property list representation. + /// A snapshot strategy for comparing encodable structures based on their property list + /// representation. + /// + /// ```swift + /// assertSnapshot(of: user, as: .plist) + /// ``` + /// + /// Records: + /// + /// ```xml + /// + /// + /// + /// + /// bio + /// Blobbed around the world. + /// id + /// 1 + /// name + /// Blobby + /// + /// + /// ``` public static var plist: Snapshotting { let encoder = PropertyListEncoder() encoder.outputFormat = .xml return .plist(encoder) } - /// A snapshot strategy for comparing encodable structures based on their property list representation. + /// A snapshot strategy for comparing encodable structures based on their property list + /// representation. /// /// - Parameter encoder: A property list encoder. public static func plist(_ encoder: PropertyListEncoder) -> Snapshotting { diff --git a/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift b/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift index f06d31f6e..c506bb329 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift @@ -1,98 +1,112 @@ #if os(macOS) -import AppKit -import Cocoa + import AppKit + import Cocoa -extension Snapshotting where Value == NSBezierPath, Format == NSImage { - /// A snapshot strategy for comparing bezier paths based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == NSBezierPath, Format == NSImage { + /// A snapshot strategy for comparing bezier paths based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing bezier paths based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { path in - // Move path info frame: - let bounds = path.bounds - let transform = AffineTransform(translationByX: -bounds.origin.x, byY: -bounds.origin.y) - path.transform(using: transform) + /// A snapshot strategy for comparing bezier paths based on pixel equality. + /// + ///``` swift + /// // Match reference perfectly. + /// assertSnapshot(of: path, as: .image) + /// + /// // Allow for a 1% pixel difference. + /// assertSnapshot(of: path, as: .image(precision: 0.99)) + /// ``` + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting { + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision + ).pullback { path in + // Move path info frame: + let bounds = path.bounds + let transform = AffineTransform(translationByX: -bounds.origin.x, byY: -bounds.origin.y) + path.transform(using: transform) - let image = NSImage(size: path.bounds.size) - image.lockFocus() - path.fill() - image.unlockFocus() - return image + let image = NSImage(size: path.bounds.size) + image.lockFocus() + path.fill() + image.unlockFocus() + return image + } } } -} -extension Snapshotting where Value == NSBezierPath, Format == String { - /// A snapshot strategy for comparing bezier paths based on pixel equality. - @available(macOS 11.0, *) - @available(iOS 11.0, *) - public static var elementsDescription: Snapshotting { - return .elementsDescription(numberFormatter: defaultNumberFormatter) - } + extension Snapshotting where Value == NSBezierPath, Format == String { + /// A snapshot strategy for comparing bezier paths based on pixel equality. + @available(macOS 11.0, *) + @available(iOS 11.0, *) + public static var elementsDescription: Snapshotting { + return .elementsDescription(numberFormatter: defaultNumberFormatter) + } - /// A snapshot strategy for comparing bezier paths based on pixel equality. - /// - /// - Parameter numberFormatter: The number formatter used for formatting points. - @available(macOS 11.0, *) - @available(iOS 11.0, *) - public static func elementsDescription(numberFormatter: NumberFormatter) -> Snapshotting { - let namesByType: [NSBezierPath.ElementType: String] = [ - .moveTo: "MoveTo", - .lineTo: "LineTo", - .curveTo: "CurveTo", - .closePath: "Close", - ] + /// A snapshot strategy for comparing bezier paths based on pixel equality. + /// + /// - Parameter numberFormatter: The number formatter used for formatting points. + @available(macOS 11.0, *) + @available(iOS 11.0, *) + public static func elementsDescription(numberFormatter: NumberFormatter) -> Snapshotting { + let namesByType: [NSBezierPath.ElementType: String] = [ + .moveTo: "MoveTo", + .lineTo: "LineTo", + .curveTo: "CurveTo", + .closePath: "Close", + ] - let numberOfPointsByType: [NSBezierPath.ElementType: Int] = [ - .moveTo: 1, - .lineTo: 1, - .curveTo: 3, - .closePath: 0, - ] + let numberOfPointsByType: [NSBezierPath.ElementType: Int] = [ + .moveTo: 1, + .lineTo: 1, + .curveTo: 3, + .closePath: 0, + ] - return SimplySnapshotting.lines.pullback { path in - var string: String = "" + return SimplySnapshotting.lines.pullback { path in + var string: String = "" - var elementPoints = [CGPoint](repeating: .zero, count: 3) - for elementIndex in 0.. Diffing { - return .init( - toData: { NSImagePNGRepresentation($0)! }, - fromData: { NSImage(data: $0)! } - ) { old, new in - guard let message = compare(old, new, precision: precision, perceptualPrecision: perceptualPrecision) else { return nil } - let difference = SnapshotTesting.diff(old, new) - let oldAttachment = XCTAttachment(image: old) - oldAttachment.name = "reference" - let newAttachment = XCTAttachment(image: new) - newAttachment.name = "failure" - let differenceAttachment = XCTAttachment(image: difference) - differenceAttachment.name = "difference" - return ( - message, - [oldAttachment, newAttachment, differenceAttachment] - ) + /// A pixel-diffing strategy for NSImage that allows customizing how precise the matching must be. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - Returns: A new diffing strategy. + public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Diffing { + return .init( + toData: { NSImagePNGRepresentation($0)! }, + fromData: { NSImage(data: $0)! } + ) { old, new in + guard + let message = compare( + old, new, precision: precision, perceptualPrecision: perceptualPrecision) + else { return nil } + let difference = SnapshotTesting.diff(old, new) + let oldAttachment = XCTAttachment(image: old) + oldAttachment.name = "reference" + let newAttachment = XCTAttachment(image: new) + newAttachment.name = "failure" + let differenceAttachment = XCTAttachment(image: difference) + differenceAttachment.name = "difference" + return ( + message, + [oldAttachment, newAttachment, differenceAttachment] + ) + } } } -} -extension Snapshotting where Value == NSImage, Format == NSImage { - /// A snapshot strategy for comparing images based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == NSImage, Format == NSImage { + /// A snapshot strategy for comparing images based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing images based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting { - return .init( - pathExtension: "png", - diffing: .image(precision: precision, perceptualPrecision: perceptualPrecision) - ) + /// A snapshot strategy for comparing images based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting { + return .init( + pathExtension: "png", + diffing: .image(precision: precision, perceptualPrecision: perceptualPrecision) + ) + } } -} -private func NSImagePNGRepresentation(_ image: NSImage) -> Data? { - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } - let rep = NSBitmapImageRep(cgImage: cgImage) - rep.size = image.size - return rep.representation(using: .png, properties: [:]) -} - -private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptualPrecision: Float) -> String? { - guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return "Reference image could not be loaded." - } - guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return "Newly-taken snapshot could not be loaded." - } - guard newCgImage.width != 0, newCgImage.height != 0 else { - return "Newly-taken snapshot is empty." - } - guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else { - return "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)." - } - guard let oldContext = context(for: oldCgImage), let oldData = oldContext.data else { - return "Reference image's data could not be loaded." - } - guard let newContext = context(for: newCgImage), let newData = newContext.data else { - return "Newly-taken snapshot's data could not be loaded." - } - let byteCount = oldContext.height * oldContext.bytesPerRow - if memcmp(oldData, newData, byteCount) == 0 { return nil } - guard - let pngData = NSImagePNGRepresentation(new), - let newerCgImage = NSImage(data: pngData)?.cgImage(forProposedRect: nil, context: nil, hints: nil), - let newerContext = context(for: newerCgImage), - let newerData = newerContext.data - else { - return "Newly-taken snapshot's data could not be loaded." - } - if memcmp(oldData, newerData, byteCount) == 0 { return nil } - if precision >= 1, perceptualPrecision >= 1 { - return "Newly-taken snapshot does not match reference." + private func NSImagePNGRepresentation(_ image: NSImage) -> Data? { + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + let rep = NSBitmapImageRep(cgImage: cgImage) + rep.size = image.size + return rep.representation(using: .png, properties: [:]) } - if perceptualPrecision < 1, #available(macOS 10.13, *) { - return perceptuallyCompare( - CIImage(cgImage: oldCgImage), - CIImage(cgImage: newCgImage), - pixelPrecision: precision, - perceptualPrecision: perceptualPrecision - ) - } else { - let oldRep = NSBitmapImageRep(cgImage: oldCgImage).bitmapData! - let newRep = NSBitmapImageRep(cgImage: newerCgImage).bitmapData! - let byteCountThreshold = Int((1 - precision) * Float(byteCount)) - var differentByteCount = 0 - for offset in 0.. String? + { + guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return "Reference image could not be loaded." + } + guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return "Newly-taken snapshot could not be loaded." + } + guard newCgImage.width != 0, newCgImage.height != 0 else { + return "Newly-taken snapshot is empty." + } + guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else { + return "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)." } - if differentByteCount > byteCountThreshold { - let actualPrecision = 1 - Float(differentByteCount) / Float(byteCount) - return "Actual image precision \(actualPrecision) is less than required \(precision)" + guard let oldContext = context(for: oldCgImage), let oldData = oldContext.data else { + return "Reference image's data could not be loaded." } + guard let newContext = context(for: newCgImage), let newData = newContext.data else { + return "Newly-taken snapshot's data could not be loaded." + } + let byteCount = oldContext.height * oldContext.bytesPerRow + if memcmp(oldData, newData, byteCount) == 0 { return nil } + guard + let pngData = NSImagePNGRepresentation(new), + let newerCgImage = NSImage(data: pngData)?.cgImage( + forProposedRect: nil, context: nil, hints: nil), + let newerContext = context(for: newerCgImage), + let newerData = newerContext.data + else { + return "Newly-taken snapshot's data could not be loaded." + } + if memcmp(oldData, newerData, byteCount) == 0 { return nil } + if precision >= 1, perceptualPrecision >= 1 { + return "Newly-taken snapshot does not match reference." + } + if perceptualPrecision < 1, #available(macOS 10.13, *) { + return perceptuallyCompare( + CIImage(cgImage: oldCgImage), + CIImage(cgImage: newCgImage), + pixelPrecision: precision, + perceptualPrecision: perceptualPrecision + ) + } else { + let oldRep = NSBitmapImageRep(cgImage: oldCgImage).bitmapData! + let newRep = NSBitmapImageRep(cgImage: newerCgImage).bitmapData! + let byteCountThreshold = Int((1 - precision) * Float(byteCount)) + var differentByteCount = 0 + for offset in 0.. byteCountThreshold { + let actualPrecision = 1 - Float(differentByteCount) / Float(byteCount) + return "Actual image precision \(actualPrecision) is less than required \(precision)" + } + } + return nil } - return nil -} -private func context(for cgImage: CGImage) -> CGContext? { - guard - let space = cgImage.colorSpace, - let context = CGContext( - data: nil, - width: cgImage.width, - height: cgImage.height, - bitsPerComponent: cgImage.bitsPerComponent, - bytesPerRow: cgImage.bytesPerRow, - space: space, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) + private func context(for cgImage: CGImage) -> CGContext? { + guard + let space = cgImage.colorSpace, + let context = CGContext( + data: nil, + width: cgImage.width, + height: cgImage.height, + bitsPerComponent: cgImage.bitsPerComponent, + bytesPerRow: cgImage.bytesPerRow, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) - return context -} + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) + return context + } -private func diff(_ old: NSImage, _ new: NSImage) -> NSImage { - let oldCiImage = CIImage(cgImage: old.cgImage(forProposedRect: nil, context: nil, hints: nil)!) - let newCiImage = CIImage(cgImage: new.cgImage(forProposedRect: nil, context: nil, hints: nil)!) - let differenceFilter = CIFilter(name: "CIDifferenceBlendMode")! - differenceFilter.setValue(oldCiImage, forKey: kCIInputImageKey) - differenceFilter.setValue(newCiImage, forKey: kCIInputBackgroundImageKey) - let maxSize = CGSize( - width: max(old.size.width, new.size.width), - height: max(old.size.height, new.size.height) - ) - let rep = NSCIImageRep(ciImage: differenceFilter.outputImage!) - let difference = NSImage(size: maxSize) - difference.addRepresentation(rep) - return difference -} + private func diff(_ old: NSImage, _ new: NSImage) -> NSImage { + let oldCiImage = CIImage(cgImage: old.cgImage(forProposedRect: nil, context: nil, hints: nil)!) + let newCiImage = CIImage(cgImage: new.cgImage(forProposedRect: nil, context: nil, hints: nil)!) + let differenceFilter = CIFilter(name: "CIDifferenceBlendMode")! + differenceFilter.setValue(oldCiImage, forKey: kCIInputImageKey) + differenceFilter.setValue(newCiImage, forKey: kCIInputBackgroundImageKey) + let maxSize = CGSize( + width: max(old.size.width, new.size.width), + height: max(old.size.height, new.size.height) + ) + let rep = NSCIImageRep(ciImage: differenceFilter.outputImage!) + let difference = NSImage(size: maxSize) + difference.addRepresentation(rep) + return difference + } #endif diff --git a/Sources/SnapshotTesting/Snapshotting/NSView.swift b/Sources/SnapshotTesting/Snapshotting/NSView.swift index 2ff70bc65..b2e7edfb0 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSView.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSView.swift @@ -1,50 +1,74 @@ #if os(macOS) -import AppKit -import Cocoa + import AppKit + import Cocoa -extension Snapshotting where Value == NSView, Format == NSImage { - /// A snapshot strategy for comparing views based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == NSView, Format == NSImage { + /// A snapshot strategy for comparing views based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing views based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: A view size override. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil) -> Snapshotting { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).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 { - fatalError("View not renderable to image at size \(view.frame.size)") - } - return view.snapshot ?? Async { callback in - addImagesForRenderedViews(view).sequence().run { views in - let bitmapRep = view.bitmapImageRepForCachingDisplay(in: view.bounds)! - view.cacheDisplay(in: view.bounds, to: bitmapRep) - let image = NSImage(size: view.bounds.size) - image.addRepresentation(bitmapRep) - callback(image) - views.forEach { $0.removeFromSuperview() } - view.frame.size = initialSize + /// A snapshot strategy for comparing views based on pixel equality. + /// + /// > Note: Snapshots must be compared on the same OS as the device that originally took the + /// > reference to avoid discrepancies between images. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: A view size override. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil + ) -> Snapshotting { + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision + ).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 { + fatalError("View not renderable to image at size \(view.frame.size)") } + return view.snapshot + ?? Async { callback in + addImagesForRenderedViews(view).sequence().run { views in + let bitmapRep = view.bitmapImageRepForCachingDisplay(in: view.bounds)! + view.cacheDisplay(in: view.bounds, to: bitmapRep) + let image = NSImage(size: view.bounds.size) + image.addRepresentation(bitmapRep) + callback(image) + views.forEach { $0.removeFromSuperview() } + view.frame.size = initialSize + } + } } } } -} -extension Snapshotting where Value == NSView, Format == String { - /// A snapshot strategy for comparing views based on a recursive description of their properties and hierarchies. - public static var recursiveDescription: Snapshotting { - return SimplySnapshotting.lines.pullback { view in - return purgePointers( - view.perform(Selector(("_subtreeDescription"))).retain().takeUnretainedValue() - as! String - ) + extension Snapshotting where Value == NSView, Format == String { + /// A snapshot strategy for comparing views based on a recursive description of their properties + /// and hierarchies. + /// + /// ``` swift + /// assertSnapshot(of: view, as: .recursiveDescription) + /// ``` + /// + /// Records: + /// + /// ``` + /// [ AF LU ] h=--- v=--- NSButton "Push Me" f=(0,0,77,32) b=(-) + /// [ A LU ] h=--- v=--- NSButtonBezelView f=(0,0,77,32) b=(-) + /// [ AF LU ] h=--- v=--- NSButtonTextField "Push Me" f=(10,6,57,16) b=(-) + /// ``` + public static var recursiveDescription: Snapshotting { + return SimplySnapshotting.lines.pullback { view in + return purgePointers( + view.perform(Selector(("_subtreeDescription"))).retain().takeUnretainedValue() + as! String + ) + } } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/NSViewController.swift b/Sources/SnapshotTesting/Snapshotting/NSViewController.swift index 6976a94ef..69ec72dde 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSViewController.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSViewController.swift @@ -1,28 +1,36 @@ #if os(macOS) -import AppKit -import Cocoa + import AppKit + import Cocoa -extension Snapshotting where Value == NSViewController, Format == NSImage { - /// A snapshot strategy for comparing view controller views based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == NSViewController, Format == NSImage { + /// A snapshot strategy for comparing view controller views based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing view controller views based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: A view size override. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil) -> Snapshotting { - return Snapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, size: size).pullback { $0.view } + /// A snapshot strategy for comparing view controller views based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: A view size override. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil + ) -> Snapshotting { + return Snapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, size: size + ).pullback { $0.view } + } } -} -extension Snapshotting where Value == NSViewController, Format == String { - /// A snapshot strategy for comparing view controller views based on a recursive description of their properties and hierarchies. - public static var recursiveDescription: Snapshotting { - return Snapshotting.recursiveDescription.pullback { $0.view } + extension Snapshotting where Value == NSViewController, Format == String { + /// A snapshot strategy for comparing view controller views based on a recursive description of + /// their properties and hierarchies. + public static var recursiveDescription: Snapshotting { + return Snapshotting.recursiveDescription.pullback { $0.view } + } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/SceneKit.swift b/Sources/SnapshotTesting/Snapshotting/SceneKit.swift index 42bd21b9d..94ff90459 100644 --- a/Sources/SnapshotTesting/Snapshotting/SceneKit.swift +++ b/Sources/SnapshotTesting/Snapshotting/SceneKit.swift @@ -1,44 +1,58 @@ #if os(iOS) || os(macOS) || os(tvOS) -import SceneKit -#if os(macOS) -import Cocoa -#elseif os(iOS) || os(tvOS) -import UIKit -#endif + import SceneKit + #if os(macOS) + import Cocoa + #elseif os(iOS) || os(tvOS) + import UIKit + #endif -#if os(macOS) -extension Snapshotting where Value == SCNScene, Format == NSImage { - /// A snapshot strategy for comparing SceneKit scenes based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting { - return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) - } -} -#elseif os(iOS) || os(tvOS) -extension Snapshotting where Value == SCNScene, Format == UIImage { - /// A snapshot strategy for comparing SceneKit scenes based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting { - return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) - } -} -#endif + #if os(macOS) + extension Snapshotting where Value == SCNScene, Format == NSImage { + /// A snapshot strategy for comparing SceneKit scenes based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: The size of the scene. + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + -> Snapshotting + { + return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + } + } + #elseif os(iOS) || os(tvOS) + extension Snapshotting where Value == SCNScene, Format == UIImage { + /// A snapshot strategy for comparing SceneKit scenes based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: The size of the scene. + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + -> Snapshotting + { + return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + } + } + #endif -fileprivate extension Snapshotting where Value == SCNScene, Format == Image { - static func scnScene(precision: Float, perceptualPrecision: Float, size: CGSize) -> Snapshotting { - return Snapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { scene in - let view = SCNView(frame: .init(x: 0, y: 0, width: size.width, height: size.height)) - view.scene = scene - return view + extension Snapshotting where Value == SCNScene, Format == Image { + fileprivate static func scnScene(precision: Float, perceptualPrecision: Float, size: CGSize) + -> Snapshotting + { + return Snapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision + ).pullback { scene in + let view = SCNView(frame: .init(x: 0, y: 0, width: size.width, height: size.height)) + view.scene = scene + return view + } } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift b/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift index 2a1f6036f..ad515050a 100644 --- a/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift +++ b/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift @@ -1,44 +1,58 @@ #if os(iOS) || os(macOS) || os(tvOS) -import SpriteKit -#if os(macOS) -import Cocoa -#elseif os(iOS) || os(tvOS) -import UIKit -#endif + import SpriteKit + #if os(macOS) + import Cocoa + #elseif os(iOS) || os(tvOS) + import UIKit + #endif -#if os(macOS) -extension Snapshotting where Value == SKScene, Format == NSImage { - /// A snapshot strategy for comparing SpriteKit scenes based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting { - return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) - } -} -#elseif os(iOS) || os(tvOS) -extension Snapshotting where Value == SKScene, Format == UIImage { - /// A snapshot strategy for comparing SpriteKit scenes based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting { - return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) - } -} -#endif + #if os(macOS) + extension Snapshotting where Value == SKScene, Format == NSImage { + /// A snapshot strategy for comparing SpriteKit scenes based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: The size of the scene. + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + -> Snapshotting + { + return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + } + } + #elseif os(iOS) || os(tvOS) + extension Snapshotting where Value == SKScene, Format == UIImage { + /// A snapshot strategy for comparing SpriteKit scenes based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: The size of the scene. + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + -> Snapshotting + { + return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + } + } + #endif -fileprivate extension Snapshotting where Value == SKScene, Format == Image { - static func skScene(precision: Float, perceptualPrecision: Float, size: CGSize) -> Snapshotting { - return Snapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { scene in - let view = SKView(frame: .init(x: 0, y: 0, width: size.width, height: size.height)) - view.presentScene(scene) - return view + extension Snapshotting where Value == SKScene, Format == Image { + fileprivate static func skScene(precision: Float, perceptualPrecision: Float, size: CGSize) + -> Snapshotting + { + return Snapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision + ).pullback { scene in + let view = SKView(frame: .init(x: 0, y: 0, width: size.width, height: size.height)) + view.presentScene(scene) + return view + } } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/String.swift b/Sources/SnapshotTesting/Snapshotting/String.swift index 3de02fe45..44aeab0b4 100644 --- a/Sources/SnapshotTesting/Snapshotting/String.swift +++ b/Sources/SnapshotTesting/Snapshotting/String.swift @@ -13,14 +13,17 @@ extension Diffing where Value == String { fromData: { String(decoding: $0, as: UTF8.self) } ) { old, new in guard old != new else { return nil } - let hunks = chunk(diff: SnapshotTesting.diff( - old.split(separator: "\n", omittingEmptySubsequences: false).map(String.init), - new.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - )) - let failure = hunks + let hunks = chunk( + diff: SnapshotTesting.diff( + old.split(separator: "\n", omittingEmptySubsequences: false).map(String.init), + new.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + )) + let failure = + hunks .flatMap { [$0.patchMark] + $0.lines } .joined(separator: "\n") - let attachment = XCTAttachment(data: Data(failure.utf8), uniformTypeIdentifier: "public.patch-file") + let attachment = XCTAttachment( + data: Data(failure.utf8), uniformTypeIdentifier: "public.patch-file") return (failure, [attachment]) } } diff --git a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift b/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift index 6dfb40adf..8d85e1f0b 100644 --- a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift +++ b/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift @@ -1,85 +1,93 @@ #if canImport(SwiftUI) -import Foundation -import SwiftUI + import Foundation + import SwiftUI + + /// The size constraint for a snapshot (similar to `PreviewLayout`). + public enum SwiftUISnapshotLayout { + #if os(iOS) || os(tvOS) + /// Center the view in a device container described by`config`. + case device(config: ViewImageConfig) + #endif + /// Center the view in a fixed size container. + case fixed(width: CGFloat, height: CGFloat) + /// Fit the view to the ideal size that fits its content. + case sizeThatFits + } -/// The size constraint for a snapshot (similar to `PreviewLayout`). -public enum SwiftUISnapshotLayout { #if os(iOS) || os(tvOS) - /// Center the view in a device container described by`config`. - case device(config: ViewImageConfig) - #endif - /// Center the view in a fixed size container. - case fixed(width: CGFloat, height: CGFloat) - /// Fit the view to the ideal size that fits its content. - case sizeThatFits -} + @available(iOS 13.0, tvOS 13.0, *) + extension Snapshotting where Value: SwiftUI.View, Format == UIImage { -#if os(iOS) || os(tvOS) -@available(iOS 13.0, tvOS 13.0, *) -extension Snapshotting where Value: SwiftUI.View, Format == UIImage { + /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. - public static var image: Snapshotting { - return .image() - } + /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. + /// + /// - Parameters: + /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render + /// `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your + /// tests and will _not_ work for framework test targets. + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - layout: A view layout override. + /// - traits: A trait collection override. + public static func image( + drawHierarchyInKeyWindow: Bool = false, + precision: Float = 1, + perceptualPrecision: Float = 1, + layout: SwiftUISnapshotLayout = .sizeThatFits, + traits: UITraitCollection = .init() + ) + -> Snapshotting + { + let config: ViewImageConfig - /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. - /// - /// - Parameters: - /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your tests and will _not_ work for framework test targets. - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - layout: A view layout override. - /// - traits: A trait collection override. - public static func image( - drawHierarchyInKeyWindow: Bool = false, - precision: Float = 1, - perceptualPrecision: Float = 1, - layout: SwiftUISnapshotLayout = .sizeThatFits, - traits: UITraitCollection = .init() - ) - -> Snapshotting { - let config: ViewImageConfig + switch layout { + #if os(iOS) || os(tvOS) + case let .device(config: deviceConfig): + config = deviceConfig + #endif + case .sizeThatFits: + config = .init(safeArea: .zero, size: nil, traits: traits) + case let .fixed(width: width, height: height): + let size = CGSize(width: width, height: height) + config = .init(safeArea: .zero, size: size, traits: traits) + } - switch layout { - #if os(iOS) || os(tvOS) - case let .device(config: deviceConfig): - config = deviceConfig - #endif - case .sizeThatFits: - config = .init(safeArea: .zero, size: nil, traits: traits) - case let .fixed(width: width, height: height): - let size = CGSize(width: width, height: height) - config = .init(safeArea: .zero, size: size, traits: traits) - } + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + ).asyncPullback { view in + var config = config - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale).asyncPullback { view in - var config = config + let controller: UIViewController - let controller: UIViewController + if config.size != nil { + controller = UIHostingController.init( + rootView: view + ) + } else { + let hostingController = UIHostingController.init(rootView: view) - if config.size != nil { - controller = UIHostingController.init( - rootView: view - ) - } else { - let hostingController = UIHostingController.init(rootView: view) + let maxSize = CGSize(width: 0.0, height: 0.0) + config.size = hostingController.sizeThatFits(in: maxSize) - let maxSize = CGSize(width: 0.0, height: 0.0) - config.size = hostingController.sizeThatFits(in: maxSize) + controller = hostingController + } - controller = hostingController + return snapshotView( + config: config, + drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, + traits: traits, + view: controller.view, + viewController: controller + ) } - - return snapshotView( - config: config, - drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, - traits: traits, - view: controller.view, - viewController: controller - ) } - } -} -#endif + } + #endif #endif diff --git a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift b/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift index d0ce9fd10..6b48d622d 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift @@ -1,49 +1,56 @@ #if os(iOS) || os(tvOS) -import UIKit + import UIKit -extension Snapshotting where Value == UIBezierPath, Format == UIImage { - /// A snapshot strategy for comparing bezier paths based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == UIBezierPath, Format == UIImage { + /// A snapshot strategy for comparing bezier paths based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing bezier paths based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - scale: The scale to use when loading the reference image from disk. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1) -> Snapshotting { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: scale).pullback { path in - let bounds = path.bounds - let format: UIGraphicsImageRendererFormat - if #available(iOS 11.0, tvOS 11.0, *) { - format = UIGraphicsImageRendererFormat.preferred() - } else { - format = UIGraphicsImageRendererFormat.default() - } - format.scale = scale - return UIGraphicsImageRenderer(bounds: bounds, format: format).image { ctx in - path.fill() + /// A snapshot strategy for comparing bezier paths based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - scale: The scale to use when loading the reference image from disk. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1 + ) -> Snapshotting { + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: scale + ).pullback { path in + let bounds = path.bounds + let format: UIGraphicsImageRendererFormat + if #available(iOS 11.0, tvOS 11.0, *) { + format = UIGraphicsImageRendererFormat.preferred() + } else { + format = UIGraphicsImageRendererFormat.default() + } + format.scale = scale + return UIGraphicsImageRenderer(bounds: bounds, format: format).image { ctx in + path.fill() + } } } } -} -@available(iOS 11.0, tvOS 11.0, *) -extension Snapshotting where Value == UIBezierPath, Format == String { - /// A snapshot strategy for comparing bezier paths based on pixel equality. - public static var elementsDescription: Snapshotting { - Snapshotting.elementsDescription.pullback { path in path.cgPath } - } + @available(iOS 11.0, tvOS 11.0, *) + extension Snapshotting where Value == UIBezierPath, Format == String { + /// A snapshot strategy for comparing bezier paths based on pixel equality. + public static var elementsDescription: Snapshotting { + Snapshotting.elementsDescription.pullback { path in path.cgPath } + } - /// A snapshot strategy for comparing bezier paths based on pixel equality. - /// - /// - Parameter numberFormatter: The number formatter used for formatting points. - public static func elementsDescription(numberFormatter: NumberFormatter) -> Snapshotting { - Snapshotting.elementsDescription( - numberFormatter: numberFormatter - ).pullback { path in path.cgPath } + /// A snapshot strategy for comparing bezier paths based on pixel equality. + /// + /// - Parameter numberFormatter: The number formatter used for formatting points. + public static func elementsDescription(numberFormatter: NumberFormatter) -> Snapshotting { + Snapshotting.elementsDescription( + numberFormatter: numberFormatter + ).pullback { path in path.cgPath } + } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/UIImage.swift b/Sources/SnapshotTesting/Snapshotting/UIImage.swift index e6ac25440..33472d3af 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIImage.swift @@ -1,251 +1,278 @@ #if os(iOS) || os(tvOS) -import UIKit -import XCTest - -extension Diffing where Value == UIImage { - /// A pixel-diffing strategy for UIImage's which requires a 100% match. - public static let image = Diffing.image() - - /// A pixel-diffing strategy for UIImage that allows customizing how precise the matching must be. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - scale: Scale to use when loading the reference image from disk. If `nil` or the `UITraitCollection`s default value of `0.0`, the screens scale is used. - /// - Returns: A new diffing strategy. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil) -> Diffing { - let imageScale: CGFloat - if let scale = scale, scale != 0.0 { - imageScale = scale - } else { - imageScale = UIScreen.main.scale - } - - return Diffing( - toData: { $0.pngData() ?? emptyImage().pngData()! }, - fromData: { UIImage(data: $0, scale: imageScale)! } - ) { old, new in - guard let message = compare(old, new, precision: precision, perceptualPrecision: perceptualPrecision) else { return nil } - let difference = SnapshotTesting.diff(old, new) - let oldAttachment = XCTAttachment(image: old) - oldAttachment.name = "reference" - let isEmptyImage = new.size == .zero - let newAttachment = XCTAttachment(image: isEmptyImage ? emptyImage() : new) - newAttachment.name = "failure" - let differenceAttachment = XCTAttachment(image: difference) - differenceAttachment.name = "difference" - return ( - message, - [oldAttachment, newAttachment, differenceAttachment] - ) + import UIKit + import XCTest + + extension Diffing where Value == UIImage { + /// A pixel-diffing strategy for UIImage's which requires a 100% match. + public static let image = Diffing.image() + + /// A pixel-diffing strategy for UIImage that allows customizing how precise the matching must be. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - scale: Scale to use when loading the reference image from disk. If `nil` or the + /// `UITraitCollection`s default value of `0.0`, the screens scale is used. + /// - Returns: A new diffing strategy. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil + ) -> Diffing { + let imageScale: CGFloat + if let scale = scale, scale != 0.0 { + imageScale = scale + } else { + imageScale = UIScreen.main.scale + } + + return Diffing( + toData: { $0.pngData() ?? emptyImage().pngData()! }, + fromData: { UIImage(data: $0, scale: imageScale)! } + ) { old, new in + guard + let message = compare( + old, new, precision: precision, perceptualPrecision: perceptualPrecision) + else { return nil } + let difference = SnapshotTesting.diff(old, new) + let oldAttachment = XCTAttachment(image: old) + oldAttachment.name = "reference" + let isEmptyImage = new.size == .zero + let newAttachment = XCTAttachment(image: isEmptyImage ? emptyImage() : new) + newAttachment.name = "failure" + let differenceAttachment = XCTAttachment(image: difference) + differenceAttachment.name = "difference" + return ( + message, + [oldAttachment, newAttachment, differenceAttachment] + ) + } } - } - - - /// Used when the image size has no width or no height to generated the default empty image - private static func emptyImage() -> UIImage { - let label = UILabel(frame: CGRect(x: 0, y: 0, width: 400, height: 80)) - label.backgroundColor = .red - label.text = "Error: No image could be generated for this view as its size was zero. Please set an explicit size in the test." - label.textAlignment = .center - label.numberOfLines = 3 - return label.asImage() - } -} -extension Snapshotting where Value == UIImage, Format == UIImage { - /// A snapshot strategy for comparing images based on pixel equality. - public static var image: Snapshotting { - return .image() + /// Used when the image size has no width or no height to generated the default empty image + private static func emptyImage() -> UIImage { + let label = UILabel(frame: CGRect(x: 0, y: 0, width: 400, height: 80)) + label.backgroundColor = .red + label.text = + "Error: No image could be generated for this view as its size was zero. Please set an explicit size in the test." + label.textAlignment = .center + label.numberOfLines = 3 + return label.asImage() + } } - /// A snapshot strategy for comparing images based on pixel equality. - /// - /// - Parameters: - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - scale: The scale of the reference image stored on disk. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil) -> Snapshotting { - return .init( - pathExtension: "png", - diffing: .image(precision: precision, perceptualPrecision: perceptualPrecision, scale: scale) - ) + extension Snapshotting where Value == UIImage, Format == UIImage { + /// A snapshot strategy for comparing images based on pixel equality. + public static var image: Snapshotting { + return .image() + } + + /// A snapshot strategy for comparing images based on pixel equality. + /// + /// - Parameters: + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - scale: The scale of the reference image stored on disk. + public static func image( + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil + ) -> Snapshotting { + return .init( + pathExtension: "png", + diffing: .image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: scale) + ) + } } -} -// remap snapshot & reference to same colorspace -private let imageContextColorSpace = CGColorSpace(name: CGColorSpace.sRGB) -private let imageContextBitsPerComponent = 8 -private let imageContextBytesPerPixel = 4 + // remap snapshot & reference to same colorspace + private let imageContextColorSpace = CGColorSpace(name: CGColorSpace.sRGB) + private let imageContextBitsPerComponent = 8 + private let imageContextBytesPerPixel = 4 -private func compare(_ old: UIImage, _ new: UIImage, precision: Float, perceptualPrecision: Float) -> String? { - guard let oldCgImage = old.cgImage else { - return "Reference image could not be loaded." - } - guard let newCgImage = new.cgImage else { - return "Newly-taken snapshot could not be loaded." - } - guard newCgImage.width != 0, newCgImage.height != 0 else { - return "Newly-taken snapshot is empty." - } - guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else { - return "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)." - } - let pixelCount = oldCgImage.width * oldCgImage.height - let byteCount = imageContextBytesPerPixel * pixelCount - var oldBytes = [UInt8](repeating: 0, count: byteCount) - guard let oldData = context(for: oldCgImage, data: &oldBytes)?.data else { - return "Reference image's data could not be loaded." - } - if let newContext = context(for: newCgImage), let newData = newContext.data { - if memcmp(oldData, newData, byteCount) == 0 { return nil } - } - var newerBytes = [UInt8](repeating: 0, count: byteCount) - guard - let pngData = new.pngData(), - let newerCgImage = UIImage(data: pngData)?.cgImage, - let newerContext = context(for: newerCgImage, data: &newerBytes), - let newerData = newerContext.data - else { - return "Newly-taken snapshot's data could not be loaded." - } - if memcmp(oldData, newerData, byteCount) == 0 { return nil } - if precision >= 1, perceptualPrecision >= 1 { - return "Newly-taken snapshot does not match reference." - } - if perceptualPrecision < 1, #available(iOS 11.0, tvOS 11.0, *) { - return perceptuallyCompare( - CIImage(cgImage: oldCgImage), - CIImage(cgImage: newCgImage), - pixelPrecision: precision, - perceptualPrecision: perceptualPrecision - ) - } else { - let byteCountThreshold = Int((1 - precision) * Float(byteCount)) - var differentByteCount = 0 - for offset in 0.. String? + { + guard let oldCgImage = old.cgImage else { + return "Reference image could not be loaded." + } + guard let newCgImage = new.cgImage else { + return "Newly-taken snapshot could not be loaded." + } + guard newCgImage.width != 0, newCgImage.height != 0 else { + return "Newly-taken snapshot is empty." + } + guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else { + return "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)." } - if differentByteCount > byteCountThreshold { - let actualPrecision = 1 - Float(differentByteCount) / Float(byteCount) - return "Actual image precision \(actualPrecision) is less than required \(precision)" + let pixelCount = oldCgImage.width * oldCgImage.height + let byteCount = imageContextBytesPerPixel * pixelCount + var oldBytes = [UInt8](repeating: 0, count: byteCount) + guard let oldData = context(for: oldCgImage, data: &oldBytes)?.data else { + return "Reference image's data could not be loaded." + } + if let newContext = context(for: newCgImage), let newData = newContext.data { + if memcmp(oldData, newData, byteCount) == 0 { return nil } + } + var newerBytes = [UInt8](repeating: 0, count: byteCount) + guard + let pngData = new.pngData(), + let newerCgImage = UIImage(data: pngData)?.cgImage, + let newerContext = context(for: newerCgImage, data: &newerBytes), + let newerData = newerContext.data + else { + return "Newly-taken snapshot's data could not be loaded." } + if memcmp(oldData, newerData, byteCount) == 0 { return nil } + if precision >= 1, perceptualPrecision >= 1 { + return "Newly-taken snapshot does not match reference." + } + if perceptualPrecision < 1, #available(iOS 11.0, tvOS 11.0, *) { + return perceptuallyCompare( + CIImage(cgImage: oldCgImage), + CIImage(cgImage: newCgImage), + pixelPrecision: precision, + perceptualPrecision: perceptualPrecision + ) + } else { + let byteCountThreshold = Int((1 - precision) * Float(byteCount)) + var differentByteCount = 0 + for offset in 0.. byteCountThreshold { + let actualPrecision = 1 - Float(differentByteCount) / Float(byteCount) + return "Actual image precision \(actualPrecision) is less than required \(precision)" + } + } + return nil } - return nil -} - -private func context(for cgImage: CGImage, data: UnsafeMutableRawPointer? = nil) -> CGContext? { - let bytesPerRow = cgImage.width * imageContextBytesPerPixel - guard - let colorSpace = imageContextColorSpace, - let context = CGContext( - data: data, - width: cgImage.width, - height: cgImage.height, - bitsPerComponent: imageContextBitsPerComponent, - bytesPerRow: bytesPerRow, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) + + private func context(for cgImage: CGImage, data: UnsafeMutableRawPointer? = nil) -> CGContext? { + let bytesPerRow = cgImage.width * imageContextBytesPerPixel + guard + let colorSpace = imageContextColorSpace, + let context = CGContext( + data: data, + width: cgImage.width, + height: cgImage.height, + bitsPerComponent: imageContextBitsPerComponent, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) - return context -} - -private func diff(_ old: UIImage, _ new: UIImage) -> UIImage { - let width = max(old.size.width, new.size.width) - let height = max(old.size.height, new.size.height) - let scale = max(old.scale, new.scale) - UIGraphicsBeginImageContextWithOptions(CGSize(width: width, height: height), true, scale) - new.draw(at: .zero) - old.draw(at: .zero, blendMode: .difference, alpha: 1) - let differenceImage = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - return differenceImage -} + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) + return context + } + + private func diff(_ old: UIImage, _ new: UIImage) -> UIImage { + let width = max(old.size.width, new.size.width) + let height = max(old.size.height, new.size.height) + let scale = max(old.scale, new.scale) + UIGraphicsBeginImageContextWithOptions(CGSize(width: width, height: height), true, scale) + new.draw(at: .zero) + old.draw(at: .zero, blendMode: .difference, alpha: 1) + let differenceImage = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + return differenceImage + } #endif #if os(iOS) || os(tvOS) || os(macOS) -import CoreImage.CIKernel -import MetalPerformanceShaders - -@available(iOS 10.0, tvOS 10.0, macOS 10.13, *) -func perceptuallyCompare(_ old: CIImage, _ new: CIImage, pixelPrecision: Float, perceptualPrecision: Float) -> String? { - let deltaOutputImage = old.applyingFilter("CILabDeltaE", parameters: ["inputImage2": new]) - let thresholdOutputImage: CIImage - do { - thresholdOutputImage = try ThresholdImageProcessorKernel.apply( - withExtent: new.extent, - inputs: [deltaOutputImage], - arguments: [ThresholdImageProcessorKernel.inputThresholdKey: (1 - perceptualPrecision) * 100] + import CoreImage.CIKernel + import MetalPerformanceShaders + + @available(iOS 10.0, tvOS 10.0, macOS 10.13, *) + func perceptuallyCompare( + _ old: CIImage, _ new: CIImage, pixelPrecision: Float, perceptualPrecision: Float + ) -> String? { + let deltaOutputImage = old.applyingFilter("CILabDeltaE", parameters: ["inputImage2": new]) + let thresholdOutputImage: CIImage + do { + thresholdOutputImage = try ThresholdImageProcessorKernel.apply( + withExtent: new.extent, + inputs: [deltaOutputImage], + arguments: [ + ThresholdImageProcessorKernel.inputThresholdKey: (1 - perceptualPrecision) * 100 + ] + ) + } catch { + return "Newly-taken snapshot's data could not be loaded. \(error)" + } + var averagePixel: Float = 0 + let context = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()]) + context.render( + thresholdOutputImage.applyingFilter( + "CIAreaAverage", parameters: [kCIInputExtentKey: new.extent]), + toBitmap: &averagePixel, + rowBytes: MemoryLayout.size, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .Rf, + colorSpace: nil ) - } catch { - return "Newly-taken snapshot's data could not be loaded. \(error)" - } - var averagePixel: Float = 0 - let context = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()]) - context.render( - thresholdOutputImage.applyingFilter("CIAreaAverage", parameters: [kCIInputExtentKey: new.extent]), - toBitmap: &averagePixel, - rowBytes: MemoryLayout.size, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .Rf, - colorSpace: nil - ) - let actualPixelPrecision = 1 - averagePixel - guard actualPixelPrecision < pixelPrecision else { return nil } - var maximumDeltaE: Float = 0 - context.render( - deltaOutputImage.applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: new.extent]), - toBitmap: &maximumDeltaE, - rowBytes: MemoryLayout.size, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .Rf, - colorSpace: nil - ) - let actualPerceptualPrecision = 1 - maximumDeltaE / 100 - if pixelPrecision < 1 { - return """ - Actual image precision \(actualPixelPrecision) is less than required \(pixelPrecision) - Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision) - """ - } else { - return "Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)" + let actualPixelPrecision = 1 - averagePixel + guard actualPixelPrecision < pixelPrecision else { return nil } + var maximumDeltaE: Float = 0 + context.render( + deltaOutputImage.applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: new.extent]), + toBitmap: &maximumDeltaE, + rowBytes: MemoryLayout.size, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .Rf, + colorSpace: nil + ) + let actualPerceptualPrecision = 1 - maximumDeltaE / 100 + if pixelPrecision < 1 { + return """ + Actual image precision \(actualPixelPrecision) is less than required \(pixelPrecision) + Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision) + """ + } else { + return + "Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)" + } } -} -// Copied from https://developer.apple.com/documentation/coreimage/ciimageprocessorkernel -@available(iOS 10.0, tvOS 10.0, macOS 10.13, *) -final class ThresholdImageProcessorKernel: CIImageProcessorKernel { - static let inputThresholdKey = "thresholdValue" - static let device = MTLCreateSystemDefaultDevice() + // Copied from https://developer.apple.com/documentation/coreimage/ciimageprocessorkernel + @available(iOS 10.0, tvOS 10.0, macOS 10.13, *) + final class ThresholdImageProcessorKernel: CIImageProcessorKernel { + static let inputThresholdKey = "thresholdValue" + static let device = MTLCreateSystemDefaultDevice() - override class func process(with inputs: [CIImageProcessorInput]?, arguments: [String: Any]?, output: CIImageProcessorOutput) throws { - guard - let device = device, - let commandBuffer = output.metalCommandBuffer, - let input = inputs?.first, - let sourceTexture = input.metalTexture, - let destinationTexture = output.metalTexture, - let thresholdValue = arguments?[inputThresholdKey] as? Float else { - return - } + override class func process( + with inputs: [CIImageProcessorInput]?, arguments: [String: Any]?, + output: CIImageProcessorOutput + ) throws { + guard + let device = device, + let commandBuffer = output.metalCommandBuffer, + let input = inputs?.first, + let sourceTexture = input.metalTexture, + let destinationTexture = output.metalTexture, + let thresholdValue = arguments?[inputThresholdKey] as? Float + else { + return + } - let threshold = MPSImageThresholdBinary( - device: device, - thresholdValue: thresholdValue, - maximumValue: 1.0, - linearGrayColorTransform: nil - ) + let threshold = MPSImageThresholdBinary( + device: device, + thresholdValue: thresholdValue, + maximumValue: 1.0, + linearGrayColorTransform: nil + ) - threshold.encode( - commandBuffer: commandBuffer, - sourceTexture: sourceTexture, - destinationTexture: destinationTexture - ) + threshold.encode( + commandBuffer: commandBuffer, + sourceTexture: sourceTexture, + destinationTexture: destinationTexture + ) + } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/UIView.swift b/Sources/SnapshotTesting/Snapshotting/UIView.swift index d2ed6e0f5..7244f67d1 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIView.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIView.swift @@ -1,30 +1,38 @@ #if os(iOS) || os(tvOS) -import UIKit + import UIKit -extension Snapshotting where Value == UIView, Format == UIImage { - /// A snapshot strategy for comparing views based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == UIView, Format == UIImage { + /// A snapshot strategy for comparing views based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing views based on pixel equality. - /// - /// - Parameters: - /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your tests and will _not_ work for framework test targets. - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: A view size override. - /// - traits: A trait collection override. - public static func image( - drawHierarchyInKeyWindow: Bool = false, - precision: Float = 1, - perceptualPrecision: Float = 1, - size: CGSize? = nil, - traits: UITraitCollection = .init() + /// A snapshot strategy for comparing views based on pixel equality. + /// + /// - Parameters: + /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render + /// `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your + /// tests and will _not_ work for framework test targets. + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: A view size override. + /// - traits: A trait collection override. + public static func image( + drawHierarchyInKeyWindow: Bool = false, + precision: Float = 1, + perceptualPrecision: Float = 1, + size: CGSize? = nil, + traits: UITraitCollection = .init() ) - -> Snapshotting { + -> Snapshotting + { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale).asyncPullback { view in + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + ).asyncPullback { view in snapshotView( config: .init(safeArea: .zero, size: size ?? view.frame.size, traits: .init()), drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, @@ -33,21 +41,42 @@ extension Snapshotting where Value == UIView, Format == UIImage { viewController: .init() ) } + } } -} -extension Snapshotting where Value == UIView, Format == String { - /// A snapshot strategy for comparing views based on a recursive description of their properties and hierarchies. - public static var recursiveDescription: Snapshotting { - return Snapshotting.recursiveDescription() - } + extension Snapshotting where Value == UIView, Format == String { + /// A snapshot strategy for comparing views based on a recursive description of their properties + /// and hierarchies. + /// + /// ``` swift + /// s// Layout on the current device. + /// assertSnapshot(of: view, as: .recursiveDescription) + /// + /// // Layout with a certain size. + /// assertSnapshot(of: view, as: .recursiveDescription(size: .init(width: 22, height: 22))) + /// + /// // Layout with a certain trait collection. + /// assertSnapshot(of: view, as: .recursiveDescription(traits: .init(horizontalSizeClass: .regular))) + /// ``` + /// + /// Records: + /// + /// ``` + /// > + /// | > + /// ``` + public static var recursiveDescription: Snapshotting { + return Snapshotting.recursiveDescription() + } - /// A snapshot strategy for comparing views based on a recursive description of their properties and hierarchies. - public static func recursiveDescription( - size: CGSize? = nil, - traits: UITraitCollection = .init() + /// A snapshot strategy for comparing views based on a recursive description of their properties + /// and hierarchies. + public static func recursiveDescription( + size: CGSize? = nil, + traits: UITraitCollection = .init() ) - -> Snapshotting { + -> Snapshotting + { return SimplySnapshotting.lines.pullback { view in let dispose = prepareView( config: .init(safeArea: .zero, size: size ?? view.frame.size, traits: traits), @@ -62,6 +91,6 @@ extension Snapshotting where Value == UIView, Format == String { as! String ) } + } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/UIViewController.swift b/Sources/SnapshotTesting/Snapshotting/UIViewController.swift index 833ec5e0a..b08b8bf59 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIViewController.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIViewController.swift @@ -1,58 +1,73 @@ #if os(iOS) || os(tvOS) -import UIKit + import UIKit -extension Snapshotting where Value == UIViewController, Format == UIImage { - /// A snapshot strategy for comparing view controller views based on pixel equality. - public static var image: Snapshotting { - return .image() - } + extension Snapshotting where Value == UIViewController, Format == UIImage { + /// A snapshot strategy for comparing view controller views based on pixel equality. + public static var image: Snapshotting { + return .image() + } - /// A snapshot strategy for comparing view controller views based on pixel equality. - /// - /// - Parameters: - /// - config: A set of device configuration settings. - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: A view size override. - /// - traits: A trait collection override. - public static func image( - on config: ViewImageConfig, - precision: Float = 1, - perceptualPrecision: Float = 1, - size: CGSize? = nil, - traits: UITraitCollection = .init() + /// A snapshot strategy for comparing view controller views based on pixel equality. + /// + /// - Parameters: + /// - config: A set of device configuration settings. + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: A view size override. + /// - traits: A trait collection override. + public static func image( + on config: ViewImageConfig, + precision: Float = 1, + perceptualPrecision: Float = 1, + size: CGSize? = nil, + traits: UITraitCollection = .init() ) - -> Snapshotting { + -> Snapshotting + { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale).asyncPullback { viewController in + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + ).asyncPullback { viewController in snapshotView( - config: size.map { .init(safeArea: config.safeArea, size: $0, traits: config.traits) } ?? config, + config: size.map { .init(safeArea: config.safeArea, size: $0, traits: config.traits) } + ?? config, drawHierarchyInKeyWindow: false, traits: traits, view: viewController.view, viewController: viewController ) } - } + } - /// A snapshot strategy for comparing view controller views based on pixel equality. - /// - /// - Parameters: - /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your tests and will _not_ work for framework test targets. - /// - precision: The percentage of pixels that must match. - /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) - /// - size: A view size override. - /// - traits: A trait collection override. - public static func image( - drawHierarchyInKeyWindow: Bool = false, - precision: Float = 1, - perceptualPrecision: Float = 1, - size: CGSize? = nil, - traits: UITraitCollection = .init() + /// A snapshot strategy for comparing view controller views based on pixel equality. + /// + /// - Parameters: + /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render + /// `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your + /// tests and will _not_ work for framework test targets. + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - size: A view size override. + /// - traits: A trait collection override. + public static func image( + drawHierarchyInKeyWindow: Bool = false, + precision: Float = 1, + perceptualPrecision: Float = 1, + size: CGSize? = nil, + traits: UITraitCollection = .init() ) - -> Snapshotting { + -> Snapshotting + { - return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale).asyncPullback { viewController in + return SimplySnapshotting.image( + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + ).asyncPullback { viewController in snapshotView( config: .init(safeArea: .zero, size: size, traits: traits), drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, @@ -61,47 +76,74 @@ extension Snapshotting where Value == UIViewController, Format == UIImage { viewController: viewController ) } + } } -} -extension Snapshotting where Value == UIViewController, Format == String { - /// A snapshot strategy for comparing view controllers based on their embedded controller hierarchy. - public static var hierarchy: Snapshotting { - return Snapshotting.lines.pullback { viewController in - let dispose = prepareView( - config: .init(), - drawHierarchyInKeyWindow: false, - traits: .init(), - view: viewController.view, - viewController: viewController - ) - defer { dispose() } - return purgePointers( - viewController.perform(Selector(("_printHierarchy"))).retain().takeUnretainedValue() as! String - ) + extension Snapshotting where Value == UIViewController, Format == String { + /// A snapshot strategy for comparing view controllers based on their embedded controller + /// hierarchy. + /// + /// ``` swift + /// assertSnapshot(of: vc, as: .hierarchy) + /// ``` + /// + /// Records: + /// + /// ``` + /// , state: appeared, view: + /// | , state: appeared, view: + /// | | , state: appeared, view: <_UIPageViewControllerContentView> + /// | | | , state: appeared, view: + /// | , state: disappeared, view: not in the window + /// | | , state: disappeared, view: (view not loaded) + /// | , state: disappeared, view: not in the window + /// | | , state: disappeared, view: (view not loaded) + /// | , state: disappeared, view: not in the window + /// | | , state: disappeared, view: (view not loaded) + /// | , state: disappeared, view: not in the window + /// | | , state: disappeared, view: (view not loaded) + /// ``` + public static var hierarchy: Snapshotting { + return Snapshotting.lines.pullback { viewController in + let dispose = prepareView( + config: .init(), + drawHierarchyInKeyWindow: false, + traits: .init(), + view: viewController.view, + viewController: viewController + ) + defer { dispose() } + return purgePointers( + viewController.perform(Selector(("_printHierarchy"))).retain().takeUnretainedValue() + as! String + ) + } } - } - /// A snapshot strategy for comparing view controller views based on a recursive description of their properties and hierarchies. - public static var recursiveDescription: Snapshotting { - return Snapshotting.recursiveDescription() - } + /// A snapshot strategy for comparing view controller views based on a recursive description of + /// their properties and hierarchies. + public static var recursiveDescription: Snapshotting { + return Snapshotting.recursiveDescription() + } - /// A snapshot strategy for comparing view controller views based on a recursive description of their properties and hierarchies. - /// - /// - Parameters: - /// - config: A set of device configuration settings. - /// - size: A view size override. - /// - traits: A trait collection override. - public static func recursiveDescription( - on config: ViewImageConfig = .init(), - size: CGSize? = nil, - traits: UITraitCollection = .init() + /// A snapshot strategy for comparing view controller views based on a recursive description of + /// their properties and hierarchies. + /// + /// - Parameters: + /// - config: A set of device configuration settings. + /// - size: A view size override. + /// - traits: A trait collection override. + public static func recursiveDescription( + on config: ViewImageConfig = .init(), + size: CGSize? = nil, + traits: UITraitCollection = .init() ) - -> Snapshotting { + -> Snapshotting + { return SimplySnapshotting.lines.pullback { viewController in let dispose = prepareView( - config: .init(safeArea: config.safeArea, size: size ?? config.size, traits: config.traits), + config: .init( + safeArea: config.safeArea, size: size ?? config.size, traits: config.traits), drawHierarchyInKeyWindow: false, traits: traits, view: viewController.view, @@ -109,10 +151,11 @@ extension Snapshotting where Value == UIViewController, Format == String { ) defer { dispose() } return purgePointers( - viewController.view.perform(Selector(("recursiveDescription"))).retain().takeUnretainedValue() + viewController.view.perform(Selector(("recursiveDescription"))).retain() + .takeUnretainedValue() as! String ) } + } } -} #endif diff --git a/Sources/SnapshotTesting/Snapshotting/URLRequest.swift b/Sources/SnapshotTesting/Snapshotting/URLRequest.swift index 76c07862a..596a8d4d9 100644 --- a/Sources/SnapshotTesting/Snapshotting/URLRequest.swift +++ b/Sources/SnapshotTesting/Snapshotting/URLRequest.swift @@ -1,10 +1,24 @@ import Foundation + #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif extension Snapshotting where Value == URLRequest, Format == String { /// A snapshot strategy for comparing requests based on raw equality. + /// + /// ``` swift + /// assertSnapshot(of: request, as: .raw) + /// ``` + /// + /// Records: + /// + /// ``` + /// POST http://localhost:8080/account + /// Cookie: pf_session={"userId":"1"} + /// + /// email=blob%40pointfree.co&name=Blob + /// ``` public static let raw = Snapshotting.raw(pretty: false) /// A snapshot strategy for comparing requests based on raw equality. @@ -12,7 +26,8 @@ extension Snapshotting where Value == URLRequest, Format == String { /// - Parameter pretty: Attempts to pretty print the body of the request (supports JSON). public static func raw(pretty: Bool) -> Snapshotting { return SimplySnapshotting.lines.pullback { (request: URLRequest) in - let method = "\(request.httpMethod ?? "GET") \(request.url?.sortingQueryItems()?.absoluteString ?? "(null)")" + let method = + "\(request.httpMethod ?? "GET") \(request.url?.sortingQueryItems()?.absoluteString ?? "(null)")" let headers = (request.allHTTPHeaderFields ?? [:]) .map { key, value in "\(key): \(value)" } @@ -21,17 +36,21 @@ extension Snapshotting where Value == URLRequest, Format == String { let body: [String] do { if pretty, #available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, *) { - body = try request.httpBody + body = + try request.httpBody .map { try JSONSerialization.jsonObject(with: $0, options: []) } - .map { try JSONSerialization.data(withJSONObject: $0, options: [.prettyPrinted, .sortedKeys]) } + .map { + try JSONSerialization.data( + withJSONObject: $0, options: [.prettyPrinted, .sortedKeys]) + } .map { ["\n\(String(decoding: $0, as: UTF8.self))"] } ?? [] } else { throw NSError(domain: "co.pointfree.Never", code: 1, userInfo: nil) } - } - catch { - body = request.httpBody + } catch { + body = + request.httpBody .map { ["\n\(String(decoding: $0, as: UTF8.self))"] } ?? [] } @@ -39,8 +58,22 @@ extension Snapshotting where Value == URLRequest, Format == String { return ([method] + headers + body).joined(separator: "\n") } } - + /// A snapshot strategy for comparing requests based on a cURL representation. + /// + // ``` swift + // assertSnapshot(of: request, as: .curl) + // ``` + // + // Records: + // + // ``` + // curl \ + // --request POST \ + // --header "Accept: text/html" \ + // --data 'pricing[billing]=monthly&pricing[lane]=individual' \ + // "https://www.pointfree.co/subscribe" + // ``` public static let curl = SimplySnapshotting.lines.pullback { (request: URLRequest) in var components = ["curl"] @@ -62,7 +95,9 @@ extension Snapshotting where Value == URLRequest, Format == String { } // Body - if let httpBodyData = request.httpBody, let httpBody = String(data: httpBodyData, encoding: .utf8) { + if let httpBodyData = request.httpBody, + let httpBody = String(data: httpBodyData, encoding: .utf8) + { var escapedBody = httpBody.replacingOccurrences(of: "\\\"", with: "\\\\\"") escapedBody = escapedBody.replacingOccurrences(of: "\"", with: "\\\"") @@ -82,8 +117,8 @@ extension Snapshotting where Value == URLRequest, Format == String { } } -private extension URL { - func sortingQueryItems() -> URL? { +extension URL { + fileprivate func sortingQueryItems() -> URL? { var components = URLComponents(url: self, resolvingAgainstBaseURL: false) let sortedQueryItems = components?.queryItems?.sorted { $0.name < $1.name } components?.queryItems = sortedQueryItems diff --git a/Tests/InlineSnapshotTestingTests/InlineSnapshotTesting.xctestplan b/Tests/InlineSnapshotTestingTests/InlineSnapshotTesting.xctestplan new file mode 100644 index 000000000..66bff19a7 --- /dev/null +++ b/Tests/InlineSnapshotTestingTests/InlineSnapshotTesting.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "54C128DD-A06E-4F40-83B2-00CB6C7027E8", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "InlineSnapshotTestingTests", + "name" : "InlineSnapshotTestingTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/InlineSnapshotTestingTests/InlineSnapshotTestingTests.swift b/Tests/InlineSnapshotTestingTests/InlineSnapshotTestingTests.swift new file mode 100644 index 000000000..8868293be --- /dev/null +++ b/Tests/InlineSnapshotTestingTests/InlineSnapshotTestingTests.swift @@ -0,0 +1,258 @@ +import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import XCTest + +final class InlineSnapshotTestingTests: XCTestCase { + override func invokeTest() { + SnapshotTesting.diffTool = "ksdiff" + // SnapshotTesting.isRecording = true + defer { + SnapshotTesting.diffTool = nil + SnapshotTesting.isRecording = false + } + super.invokeTest() + } + + func testInlineSnapshot() { + assertInlineSnapshot(of: ["Hello", "World"], as: .dump) { + """ + ▿ 2 elements + - "Hello" + - "World" + + """ + } + } + + func testInlineSnapshot_NamedTrailingClosure() { + assertInlineSnapshot( + of: ["Hello", "World"], as: .dump, + matches: { + """ + ▿ 2 elements + - "Hello" + - "World" + + """ + }) + } + + func testInlineSnapshot_Escaping() { + assertInlineSnapshot(of: "Hello\"\"\"#, world", as: .lines) { + ##""" + Hello"""#, world + """## + } + } + + func testCustomInlineSnapshot() { + assertCustomInlineSnapshot { + "Hello" + } is: { + """ + - "Hello" + + """ + } + } + + func testCustomInlineSnapshot_Multiline() { + assertCustomInlineSnapshot { + """ + "Hello" + "World" + """ + } is: { + #""" + - "\"Hello\"\n\"World\"" + + """# + } + } + + func testCustomInlineSnapshot_SingleTrailingClosure() { + assertCustomInlineSnapshot(of: { "Hello" }) { + """ + - "Hello" + + """ + } + } + + func testCustomInlineSnapshot_MultilineSingleTrailingClosure() { + assertCustomInlineSnapshot( + of: { "Hello" } + ) { + """ + - "Hello" + + """ + } + } + + func testCustomInlineSnapshot_NoTrailingClosure() { + assertCustomInlineSnapshot( + of: { "Hello" }, + is: { + """ + - "Hello" + + """ + } + ) + } + + func testMultipleInlineSnapshots() { + func assertResponse( + of url: () -> String, + head: (() -> String)? = nil, + body: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) { + assertInlineSnapshot( + of: """ + HTTP/1.1 200 OK + Content-Type: text/html; charset=utf-8 + """, + as: .lines, + message: "Head did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "head", + trailingClosureOffset: 1 + ), + matches: head, + file: file, + function: function, + line: line, + column: column + ) + assertInlineSnapshot( + of: """ + + + + + Point-Free + + + +

What's the point?

+ + + """, + as: .lines, + message: "Body did not match", + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "body", + trailingClosureOffset: 2 + ), + matches: body, + file: file, + function: function, + line: line, + column: column + ) + } + + assertResponse { + """ + https://www.pointfree.co/ + """ + } head: { + """ + HTTP/1.1 200 OK + Content-Type: text/html; charset=utf-8 + """ + } body: { + """ + + + + + Point-Free + + + +

What's the point?

+ + + """ + } + } + + func testAsyncThrowing() async throws { + func assertAsyncThrowingInlineSnapshot( + of value: () -> String, + is expected: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) async throws { + assertInlineSnapshot( + of: value(), + as: .dump, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "is", + trailingClosureOffset: 1 + ), + matches: expected, + file: file, + function: function, + line: line, + column: column + ) + } + + try await assertAsyncThrowingInlineSnapshot { + "Hello" + } is: { + """ + - "Hello" + + """ + } + } + + func testNestedInClosureFunction() { + func withDependencies(operation: () -> Void) { + operation() + } + + withDependencies { + assertInlineSnapshot(of: "Hello", as: .dump) { + """ + - "Hello" + + """ + } + } + } +} + +private func assertCustomInlineSnapshot( + of value: () -> String, + is expected: (() -> String)? = nil, + file: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) { + assertInlineSnapshot( + of: value(), + as: .dump, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "is", + trailingClosureOffset: 1 + ), + matches: expected, + file: file, + function: function, + line: line, + column: column + ) +} diff --git a/Tests/SnapshotTestingTests/InlineSnapshotTests.swift b/Tests/SnapshotTestingTests/InlineSnapshotTests.swift deleted file mode 100644 index 1ad8021af..000000000 --- a/Tests/SnapshotTestingTests/InlineSnapshotTests.swift +++ /dev/null @@ -1,550 +0,0 @@ -import XCTest -@testable import SnapshotTesting - -class InlineSnapshotTests: XCTestCase { - - func testCreateSnapshotSingleLine() { - let diffable = "NEW_SNAPSHOT" - let source = """ - _assertInlineSnapshot(matching: diffable, as: .lines, with: "") - """ - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotMultiLine() { - let diffable = "NEW_SNAPSHOT" - let source = #""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - """) - """# - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshot() { - let diffable = "NEW_SNAPSHOT" - let source = #""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - OLD_SNAPSHOT - """) - """# - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithMoreLines() { - let diffable = "NEW_SNAPSHOT\nNEW_SNAPSHOT" - let source = #""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - OLD_SNAPSHOT - """) - """# - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithLessLines() { - let diffable = "NEW_SNAPSHOT" - let source = #""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - OLD_SNAPSHOT - OLD_SNAPSHOT - """) - """# - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithExtendedDelimiterSingleLine1() { - let diffable = #"\""# - let source = """ - _assertInlineSnapshot(matching: diffable, as: .lines, with: "") - """ - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotEscapedNewlineLastLine() { - let diffable = #""" - abc \ - cde \ - """# - let source = """ - _assertInlineSnapshot(matching: diffable, as: .lines, with: "") - """ - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithExtendedDelimiterSingleLine2() { - let diffable = ##"\"""#"## - let source = ##""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: "") - """## - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithExtendedDelimiter1() { - let diffable = #"\""# - let source = ##""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - """#) - """## - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithExtendedDelimiter2() { - let diffable = ##"\"""#"## - let source = ###""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - """##) - """### - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithLongerExtendedDelimiter1() { - let diffable = #"\""# - let source = ###""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - """##) - """### - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithLongerExtendedDelimiter2() { - let diffable = ##"\"""#"## - let source = ####""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: ###""" - """###) - """#### - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithShorterExtendedDelimiter1() { - let diffable = #"\""# - let source = #""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - """) - """# - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testCreateSnapshotWithShorterExtendedDelimiter2() { - let diffable = ##"\"""#"## - let source = ##""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - """#) - """## - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithExtendedDelimiter1() { - let diffable = #"\""# - let source = ##""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - """## - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithExtendedDelimiter2() { - let diffable = ##"\"""#"## - let source = ###""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - "# - """##) - """### - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithLongerExtendedDelimiter1() { - let diffable = #"\""# - let source = #""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - \" - """) - """# - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithLongerExtendedDelimiter2() { - let diffable = ##"\"""#"## - let source = ##""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - "# - """#) - """## - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithShorterExtendedDelimiter1() { - let diffable = #"\""# - let source = ###""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \" - """##) - """### - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSnapshotWithShorterExtendedDelimiter2() { - let diffable = ##"\"""#"## - let source = ####""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: ###""" - "# - """###) - """#### - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } - - func testUpdateSeveralSnapshotsWithMoreLines() { - let diffable1 = """ - NEW_SNAPSHOT - with two lines - """ - - let diffable2 = "NEW_SNAPSHOT" - - let source = """ - _assertInlineSnapshot(matching: diffable, as: .lines, with: \""" - OLD_SNAPSHOT - \""") - - _assertInlineSnapshot(matching: diffable2, as: .lines, with: \""" - OLD_SNAPSHOT - \""") - """ - - var recordings: Recordings = [:] - let sourceAfterFirstSnapshot = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable1, fileName: "filename", lineIndex: 1) - ).sourceCode - - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: sourceAfterFirstSnapshot, diffable: diffable2, fileName: "filename", lineIndex: 5) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable1, diffable2: diffable2) - } - - func testUpdateSeveralSnapshotsWithLessLines() { - let diffable1 = """ - NEW_SNAPSHOT - """ - - let diffable2 = "NEW_SNAPSHOT" - - let source = """ - _assertInlineSnapshot(matching: diffable, as: .lines, with: \""" - OLD_SNAPSHOT - with two lines - \""") - - _assertInlineSnapshot(matching: diffable2, as: .lines, with: \""" - OLD_SNAPSHOT - \""") - """ - - var recordings: Recordings = [:] - let sourceAfterFirstSnapshot = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable1, fileName: "filename", lineIndex: 1) - ).sourceCode - - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: sourceAfterFirstSnapshot, diffable: diffable2, fileName: "filename", lineIndex: 6) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable1, diffable2: diffable2) - } - - func testUpdateSeveralSnapshotsSwapingLines1() { - let diffable1 = """ - NEW_SNAPSHOT - with two lines - """ - - let diffable2 = """ - NEW_SNAPSHOT - """ - - let source = """ - _assertInlineSnapshot(matching: diffable, as: .lines, with: \""" - OLD_SNAPSHOT - \""") - - _assertInlineSnapshot(matching: diffable2, as: .lines, with: \""" - OLD_SNAPSHOT - with two lines - \""") - """ - - var recordings: Recordings = [:] - let sourceAfterFirstSnapshot = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable1, fileName: "filename", lineIndex: 1) - ).sourceCode - - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: sourceAfterFirstSnapshot, diffable: diffable2, fileName: "filename", lineIndex: 5) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable1, diffable2: diffable2) - } - - func testUpdateSeveralSnapshotsSwapingLines2() { - let diffable1 = """ - NEW_SNAPSHOT - """ - - let diffable2 = """ - NEW_SNAPSHOT - with two lines - """ - - let source = """ - _assertInlineSnapshot(matching: diffable, as: .lines, with: \""" - OLD_SNAPSHOT - with two lines - \""") - - _assertInlineSnapshot(matching: diffable2, as: .lines, with: \""" - OLD_SNAPSHOT - \""") - """ - - var recordings: Recordings = [:] - let sourceAfterFirstSnapshot = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable1, fileName: "filename", lineIndex: 1) - ).sourceCode - - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: sourceAfterFirstSnapshot, diffable: diffable2, fileName: "filename", lineIndex: 6) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable1, diffable2: diffable2) - } - - func testUpdateSnapshotCombined1() { - let diffable = ##""" - ▿ User - - bio: "Blobbed around the world." - - id: 1 - - name: "Bl#\"\"#obby" - """## - - let source = ######""" - _assertInlineSnapshot(matching: diffable, as: .lines, with: #####""" - """#####) - """###### - - var recordings: Recordings = [:] - let newSource = try! writeInlineSnapshot( - &recordings, - Context(sourceCode: source, diffable: diffable, fileName: "filename", lineIndex: 1) - ).sourceCode - - assertSnapshot(source: newSource, diffable: diffable) - } -} - -func assertSnapshot(source: String, diffable: String, record: Bool = false, file: StaticString = #file, testName: String = #function, line: UInt = #line) { - let indentedDiffable = diffable.split(separator: "\n").map { " " + $0 }.joined(separator: "\n") - let indentedSource = source.split(separator: "\n").map { " " + $0 }.joined(separator: "\n") - let decoratedCode = ########""" - import XCTest - @testable import SnapshotTesting - extension InlineSnapshotsValidityTests { - func \########(testName) { - let diffable = #######""" - \########(indentedDiffable) - """####### - - \########(indentedSource) - } - } - """######## - assertSnapshot(of: decoratedCode, as: .swift, record: record, file: file, testName: testName, line: line) -} - -func assertSnapshot(source: String, diffable: String, diffable2: String, record: Bool = false, file: StaticString = #file, testName: String = #function, line: UInt = #line) { - let indentedDiffable = diffable.split(separator: "\n").map { " " + $0 }.joined(separator: "\n") - let indentedDiffable2 = diffable2.split(separator: "\n").map { " " + $0 }.joined(separator: "\n") - let indentedSource = source.split(separator: "\n").map { " " + $0 }.joined(separator: "\n") - let decoratedCode = ########""" - import XCTest - @testable import SnapshotTesting - extension InlineSnapshotsValidityTests { - func \########(testName) { - let diffable = #######""" - \########(indentedDiffable) - """####### - - let diffable2 = #######""" - \########(indentedDiffable2) - """####### - - \########(indentedSource) - } - } - """######## - assertSnapshot(of: decoratedCode, as: .swift, record: record, file: file, testName: testName, line: line) -} - -extension Snapshotting where Value == String, Format == String { - public static var swift: Snapshotting { - var snapshotting = Snapshotting(pathExtension: "txt", diffing: .lines) - snapshotting.pathExtension = "swift" - return snapshotting - } -} - -// Class that is extended with the generated code to check that it builds. -// Besides that, the generated code is a test itself, which tests that the -// snapshotted value is equal to the original value. -// With this test we check that we escaped correctly -// e.g. if we enclose \" in """ """ instead of #""" """#, -// the character sequence will be interpreted as " instead of \" -// The generated tests check this issues. -class InlineSnapshotsValidityTests: XCTestCase {} diff --git a/Tests/SnapshotTestingTests/SnapshotTestingTests.swift b/Tests/SnapshotTestingTests/SnapshotTestingTests.swift index 2ee2c7e02..07aa425a1 100644 --- a/Tests/SnapshotTestingTests/SnapshotTestingTests.swift +++ b/Tests/SnapshotTestingTests/SnapshotTestingTests.swift @@ -1,29 +1,30 @@ import Foundation +import XCTest + +@testable import SnapshotTesting + #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif #if canImport(SceneKit) -import SceneKit + import SceneKit #endif #if canImport(SpriteKit) -import SpriteKit -import SwiftUI + import SpriteKit + import SwiftUI #endif #if canImport(WebKit) -import WebKit + import WebKit #endif #if canImport(UIKit) -import UIKit.UIView + import UIKit.UIView #endif -import XCTest - -@testable import SnapshotTesting final class SnapshotTestingTests: XCTestCase { override func setUp() { super.setUp() diffTool = "ksdiff" -// isRecording = true + // isRecording = true } override func tearDown() { @@ -35,12 +36,6 @@ final class SnapshotTestingTests: XCTestCase { struct User { let id: Int, name: String, bio: String } let user = User(id: 1, name: "Blobby", bio: "Blobbed around the world.") assertSnapshot(of: user, as: .dump) - _assertInlineSnapshot(matching: user, as: .dump, with: """ - ▿ User - - bio: "Blobbed around the world." - - id: 1 - - name: "Blobby" - """) } @available(macOS 10.13, tvOS 11.0, *) @@ -62,44 +57,22 @@ final class SnapshotTestingTests: XCTestCase { assertSnapshot(of: "Hello, world!", as: .dump, named: "string") assertSnapshot(of: "Hello, world!".dropLast(8), as: .dump, named: "substring") assertSnapshot(of: URL(string: "https://www.pointfree.co")!, as: .dump, named: "url") - // Inline - _assertInlineSnapshot(matching: "a" as Character, as: .dump, with: """ - - "a" - """) - _assertInlineSnapshot(matching: Data("Hello, world!".utf8), as: .dump, with: """ - - 13 bytes - """) - _assertInlineSnapshot(matching: Date(timeIntervalSinceReferenceDate: 0), as: .dump, with: """ - - 2001-01-01T00:00:00Z - """) - _assertInlineSnapshot(matching: NSObject(), as: .dump, with: """ - - - """) - _assertInlineSnapshot(matching: "Hello, world!", as: .dump, with: """ - - "Hello, world!" - """) - _assertInlineSnapshot(matching: "Hello, world!".dropLast(8), as: .dump, with: """ - - "Hello" - """) - _assertInlineSnapshot(matching: URL(string: "https://www.pointfree.co")!, as: .dump, with: """ - - https://www.pointfree.co - """) } func testAutolayout() { #if os(iOS) - let vc = UIViewController() - vc.view.translatesAutoresizingMaskIntoConstraints = false - let subview = UIView() - subview.translatesAutoresizingMaskIntoConstraints = false - vc.view.addSubview(subview) - NSLayoutConstraint.activate([ - subview.topAnchor.constraint(equalTo: vc.view.topAnchor), - subview.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor), - subview.leftAnchor.constraint(equalTo: vc.view.leftAnchor), - subview.rightAnchor.constraint(equalTo: vc.view.rightAnchor), + let vc = UIViewController() + vc.view.translatesAutoresizingMaskIntoConstraints = false + let subview = UIView() + subview.translatesAutoresizingMaskIntoConstraints = false + vc.view.addSubview(subview) + NSLayoutConstraint.activate([ + subview.topAnchor.constraint(equalTo: vc.view.topAnchor), + subview.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor), + subview.leftAnchor.constraint(equalTo: vc.view.leftAnchor), + subview.rightAnchor.constraint(equalTo: vc.view.rightAnchor), ]) - assertSnapshot(of: vc, as: .image) + assertSnapshot(of: vc, as: .image) #endif } @@ -111,24 +84,6 @@ final class SnapshotTestingTests: XCTestCase { set: [.init(name: "Brandon"), .init(name: "Stephen")] ) assertSnapshot(of: set, as: .dump) - _assertInlineSnapshot(matching: set, as: .dump, with: """ - ▿ DictionarySetContainer - ▿ dict: 3 key/value pairs - ▿ (2 elements) - - key: "a" - - value: 1 - ▿ (2 elements) - - key: "b" - - value: 2 - ▿ (2 elements) - - key: "c" - - value: 3 - ▿ set: 2 members - ▿ Person - - name: "Brandon" - ▿ Person - - name: "Stephen" - """) } func testCaseIterable() { @@ -136,9 +91,9 @@ final class SnapshotTestingTests: XCTestCase { case up, down, left, right var rotatedLeft: Direction { switch self { - case .up: return .left - case .down: return .right - case .left: return .down + case .up: return .left + case .down: return .right + case .left: return .down case .right: return .up } } @@ -152,24 +107,24 @@ final class SnapshotTestingTests: XCTestCase { func testCGPath() { #if os(iOS) || os(tvOS) || os(macOS) - let path = CGPath.heart + let path = CGPath.heart - let osName: String - #if os(iOS) - osName = "iOS" - #elseif os(tvOS) - osName = "tvOS" - #elseif os(macOS) - osName = "macOS" - #endif + let osName: String + #if os(iOS) + osName = "iOS" + #elseif os(tvOS) + osName = "tvOS" + #elseif os(macOS) + osName = "macOS" + #endif - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot(of: path, as: .image, named: osName) - } + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot(of: path, as: .image, named: osName) + } - if #available(iOS 11.0, OSX 10.13, tvOS 11.0, *) { - assertSnapshot(of: path, as: .elementsDescription, named: osName) - } + if #available(iOS 11.0, OSX 10.13, tvOS 11.0, *) { + assertSnapshot(of: path, as: .elementsDescription, named: osName) + } #endif } @@ -190,27 +145,27 @@ final class SnapshotTestingTests: XCTestCase { } func testMixedViews() { -// #if os(iOS) || os(macOS) -// // NB: CircleCI crashes while trying to instantiate SKView. -// if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { -// let webView = WKWebView(frame: .init(x: 0, y: 0, width: 50, height: 50)) -// webView.loadHTMLString("🌎", baseURL: nil) -// -// let skView = SKView(frame: .init(x: 50, y: 0, width: 50, height: 50)) -// let scene = SKScene(size: .init(width: 50, height: 50)) -// let node = SKShapeNode(circleOfRadius: 15) -// node.fillColor = .red -// node.position = .init(x: 25, y: 25) -// scene.addChild(node) -// skView.presentScene(scene) -// -// let view = View(frame: .init(x: 0, y: 0, width: 100, height: 50)) -// view.addSubview(webView) -// view.addSubview(skView) -// -// assertSnapshot(of: view, as: .image, named: platform) -// } -// #endif + // #if os(iOS) || os(macOS) + // // NB: CircleCI crashes while trying to instantiate SKView. + // if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + // let webView = WKWebView(frame: .init(x: 0, y: 0, width: 50, height: 50)) + // webView.loadHTMLString("🌎", baseURL: nil) + // + // let skView = SKView(frame: .init(x: 50, y: 0, width: 50, height: 50)) + // let scene = SKScene(size: .init(width: 50, height: 50)) + // let node = SKShapeNode(circleOfRadius: 15) + // node.fillColor = .red + // node.position = .init(x: 25, y: 25) + // scene.addChild(node) + // skView.presentScene(scene) + // + // let view = View(frame: .init(x: 0, y: 0, width: 100, height: 50)) + // view.addSubview(webView) + // view.addSubview(skView) + // + // assertSnapshot(of: view, as: .image, named: platform) + // } + // #endif } func testMultipleSnapshots() { @@ -226,746 +181,901 @@ final class SnapshotTestingTests: XCTestCase { func testNSBezierPath() { #if os(macOS) - let path = NSBezierPath.heart + let path = NSBezierPath.heart - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot(of: path, as: .image, named: "macOS") - } + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot(of: path, as: .image, named: "macOS") + } - assertSnapshot(of: path, as: .elementsDescription, named: "macOS") + assertSnapshot(of: path, as: .elementsDescription, named: "macOS") #endif } func testNSView() { #if os(macOS) - let button = NSButton() - button.bezelStyle = .rounded - button.title = "Push Me" - button.sizeToFit() - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot(of: button, as: .image) - assertSnapshot(of: button, as: .recursiveDescription) - } + let button = NSButton() + button.bezelStyle = .rounded + button.title = "Push Me" + button.sizeToFit() + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot(of: button, as: .image) + assertSnapshot(of: button, as: .recursiveDescription) + } #endif } - + func testNSViewWithLayer() { #if os(macOS) - let view = NSView() - view.frame = CGRect(x: 0.0, y: 0.0, width: 10.0, height: 10.0) - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.green.cgColor - view.layer?.cornerRadius = 5 - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot(of: view, as: .image) - assertSnapshot(of: view, as: .recursiveDescription) - } + let view = NSView() + view.frame = CGRect(x: 0.0, y: 0.0, width: 10.0, height: 10.0) + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.green.cgColor + view.layer?.cornerRadius = 5 + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot(of: view, as: .image) + assertSnapshot(of: view, as: .recursiveDescription) + } #endif } func testPrecision() { #if os(iOS) || os(macOS) || os(tvOS) - #if os(iOS) || os(tvOS) - let label = UILabel() - #if os(iOS) - label.frame = CGRect(origin: .zero, size: CGSize(width: 43.5, height: 20.5)) - #elseif os(tvOS) - label.frame = CGRect(origin: .zero, size: CGSize(width: 98, height: 46)) - #endif - label.backgroundColor = .white - #elseif os(macOS) - let label = NSTextField() - label.frame = CGRect(origin: .zero, size: CGSize(width: 37, height: 16)) - label.backgroundColor = .white - label.isBezeled = false - label.isEditable = false - #endif - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - label.text = "Hello." - assertSnapshot(of: label, as: .image(precision: 0.9), named: platform) - label.text = "Hello" - assertSnapshot(of: label, as: .image(precision: 0.9), named: platform) - } + #if os(iOS) || os(tvOS) + let label = UILabel() + #if os(iOS) + label.frame = CGRect(origin: .zero, size: CGSize(width: 43.5, height: 20.5)) + #elseif os(tvOS) + label.frame = CGRect(origin: .zero, size: CGSize(width: 98, height: 46)) + #endif + label.backgroundColor = .white + #elseif os(macOS) + let label = NSTextField() + label.frame = CGRect(origin: .zero, size: CGSize(width: 37, height: 16)) + label.backgroundColor = .white + label.isBezeled = false + label.isEditable = false + #endif + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + label.text = "Hello." + assertSnapshot(of: label, as: .image(precision: 0.9), named: platform) + label.text = "Hello" + assertSnapshot(of: label, as: .image(precision: 0.9), named: platform) + } #endif } func testImagePrecision() throws { #if os(iOS) || os(tvOS) || os(macOS) - let imageURL = URL(fileURLWithPath: String(#file), isDirectory: false) - .deletingLastPathComponent() - .appendingPathComponent("__Fixtures__/testImagePrecision.reference.png") - #if os(iOS) || os(tvOS) - let image = try XCTUnwrap(UIImage(contentsOfFile: imageURL.path)) - #elseif os(macOS) - let image = try XCTUnwrap(NSImage(byReferencing: imageURL)) - #endif + let imageURL = URL(fileURLWithPath: String(#file), isDirectory: false) + .deletingLastPathComponent() + .appendingPathComponent("__Fixtures__/testImagePrecision.reference.png") + #if os(iOS) || os(tvOS) + let image = try XCTUnwrap(UIImage(contentsOfFile: imageURL.path)) + #elseif os(macOS) + let image = try XCTUnwrap(NSImage(byReferencing: imageURL)) + #endif - assertSnapshot(of: image, as: .image(precision: 0.995), named: "exact") - if #available(iOS 11.0, tvOS 11.0, macOS 10.13, *) { - assertSnapshot(of: image, as: .image(perceptualPrecision: 0.98), named: "perceptual") - } + assertSnapshot(of: image, as: .image(precision: 0.995), named: "exact") + if #available(iOS 11.0, tvOS 11.0, macOS 10.13, *) { + assertSnapshot(of: image, as: .image(perceptualPrecision: 0.98), named: "perceptual") + } #endif } func testSCNView() { -// #if os(iOS) || os(macOS) || os(tvOS) -// // NB: CircleCI crashes while trying to instantiate SCNView. -// if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { -// let scene = SCNScene() -// -// let sphereGeometry = SCNSphere(radius: 3) -// sphereGeometry.segmentCount = 200 -// let sphereNode = SCNNode(geometry: sphereGeometry) -// sphereNode.position = SCNVector3Zero -// scene.rootNode.addChildNode(sphereNode) -// -// sphereGeometry.firstMaterial?.diffuse.contents = URL(fileURLWithPath: String(#file), isDirectory: false) -// .deletingLastPathComponent() -// .appendingPathComponent("__Fixtures__/earth.png") -// -// let cameraNode = SCNNode() -// cameraNode.camera = SCNCamera() -// cameraNode.position = SCNVector3Make(0, 0, 8) -// scene.rootNode.addChildNode(cameraNode) -// -// let omniLight = SCNLight() -// omniLight.type = .omni -// let omniLightNode = SCNNode() -// omniLightNode.light = omniLight -// omniLightNode.position = SCNVector3Make(10, 10, 10) -// scene.rootNode.addChildNode(omniLightNode) -// -// assertSnapshot( -// of: scene, -// as: .image(size: .init(width: 500, height: 500)), -// named: platform -// ) -// } -// #endif + // #if os(iOS) || os(macOS) || os(tvOS) + // // NB: CircleCI crashes while trying to instantiate SCNView. + // if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + // let scene = SCNScene() + // + // let sphereGeometry = SCNSphere(radius: 3) + // sphereGeometry.segmentCount = 200 + // let sphereNode = SCNNode(geometry: sphereGeometry) + // sphereNode.position = SCNVector3Zero + // scene.rootNode.addChildNode(sphereNode) + // + // sphereGeometry.firstMaterial?.diffuse.contents = URL(fileURLWithPath: String(#file), isDirectory: false) + // .deletingLastPathComponent() + // .appendingPathComponent("__Fixtures__/earth.png") + // + // let cameraNode = SCNNode() + // cameraNode.camera = SCNCamera() + // cameraNode.position = SCNVector3Make(0, 0, 8) + // scene.rootNode.addChildNode(cameraNode) + // + // let omniLight = SCNLight() + // omniLight.type = .omni + // let omniLightNode = SCNNode() + // omniLightNode.light = omniLight + // omniLightNode.position = SCNVector3Make(10, 10, 10) + // scene.rootNode.addChildNode(omniLightNode) + // + // assertSnapshot( + // of: scene, + // as: .image(size: .init(width: 500, height: 500)), + // named: platform + // ) + // } + // #endif } func testSKView() { -// #if os(iOS) || os(macOS) || os(tvOS) -// // NB: CircleCI crashes while trying to instantiate SKView. -// if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { -// let scene = SKScene(size: .init(width: 50, height: 50)) -// let node = SKShapeNode(circleOfRadius: 15) -// node.fillColor = .red -// node.position = .init(x: 25, y: 25) -// scene.addChild(node) -// -// assertSnapshot( -// of: scene, -// as: .image(size: .init(width: 50, height: 50)), -// named: platform -// ) -// } -// #endif + // #if os(iOS) || os(macOS) || os(tvOS) + // // NB: CircleCI crashes while trying to instantiate SKView. + // if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + // let scene = SKScene(size: .init(width: 50, height: 50)) + // let node = SKShapeNode(circleOfRadius: 15) + // node.fillColor = .red + // node.position = .init(x: 25, y: 25) + // scene.addChild(node) + // + // assertSnapshot( + // of: scene, + // as: .image(size: .init(width: 50, height: 50)), + // named: platform + // ) + // } + // #endif } func testTableViewController() { #if os(iOS) - class TableViewController: UITableViewController { - override func viewDidLoad() { - super.viewDidLoad() - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 10 - } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - cell.textLabel?.text = "\(indexPath.row)" - return cell + class TableViewController: UITableViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int + { + return 10 + } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) + -> UITableViewCell + { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = "\(indexPath.row)" + return cell + } } - } - let tableViewController = TableViewController() - assertSnapshot(of: tableViewController, as: .image(on: .iPhoneSe)) + let tableViewController = TableViewController() + assertSnapshot(of: tableViewController, as: .image(on: .iPhoneSe)) #endif } func testAssertMultipleSnapshot() { #if os(iOS) - class TableViewController: UITableViewController { - override func viewDidLoad() { - super.viewDidLoad() - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 10 - } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - cell.textLabel?.text = "\(indexPath.row)" - return cell + class TableViewController: UITableViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int + { + return 10 + } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) + -> UITableViewCell + { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = "\(indexPath.row)" + return cell + } } - } - let tableViewController = TableViewController() - assertSnapshots(of: tableViewController, as: ["iPhoneSE-image" : .image(on: .iPhoneSe), "iPad-image" : .image(on: .iPadMini)]) - assertSnapshots(of: tableViewController, as: [.image(on: .iPhoneX), .image(on: .iPhoneXsMax)]) + let tableViewController = TableViewController() + assertSnapshots( + of: tableViewController, + as: ["iPhoneSE-image": .image(on: .iPhoneSe), "iPad-image": .image(on: .iPadMini)]) + assertSnapshots( + of: tableViewController, as: [.image(on: .iPhoneX), .image(on: .iPhoneXsMax)]) #endif } func testTraits() { #if os(iOS) || os(tvOS) - if #available(iOS 11.0, tvOS 11.0, *) { - class MyViewController: UIViewController { - let topLabel = UILabel() - let leadingLabel = UILabel() - let trailingLabel = UILabel() - let bottomLabel = UILabel() - - override func viewDidLoad() { - super.viewDidLoad() - - self.navigationItem.leftBarButtonItem = .init(barButtonSystemItem: .add, target: nil, action: nil) + if #available(iOS 11.0, tvOS 11.0, *) { + class MyViewController: UIViewController { + let topLabel = UILabel() + let leadingLabel = UILabel() + let trailingLabel = UILabel() + let bottomLabel = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + + self.navigationItem.leftBarButtonItem = .init( + barButtonSystemItem: .add, target: nil, action: nil) + + self.view.backgroundColor = .white + + self.topLabel.text = "What's" + self.leadingLabel.text = "the" + self.trailingLabel.text = "point" + self.bottomLabel.text = "?" + + self.topLabel.translatesAutoresizingMaskIntoConstraints = false + self.leadingLabel.translatesAutoresizingMaskIntoConstraints = false + self.trailingLabel.translatesAutoresizingMaskIntoConstraints = false + self.bottomLabel.translatesAutoresizingMaskIntoConstraints = false + + self.view.addSubview(self.topLabel) + self.view.addSubview(self.leadingLabel) + self.view.addSubview(self.trailingLabel) + self.view.addSubview(self.bottomLabel) + + NSLayoutConstraint.activate([ + self.topLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + self.topLabel.centerXAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), + self.leadingLabel.leadingAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), + self.leadingLabel.trailingAnchor.constraint( + lessThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), + // self.leadingLabel.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), + self.leadingLabel.centerYAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), + self.trailingLabel.leadingAnchor.constraint( + greaterThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), + self.trailingLabel.trailingAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.trailingAnchor), + self.trailingLabel.centerYAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), + self.bottomLabel.bottomAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + self.bottomLabel.centerXAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), + ]) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + self.topLabel.font = .preferredFont( + forTextStyle: .headline, compatibleWith: self.traitCollection) + self.leadingLabel.font = .preferredFont( + forTextStyle: .body, compatibleWith: self.traitCollection) + self.trailingLabel.font = .preferredFont( + forTextStyle: .body, compatibleWith: self.traitCollection) + self.bottomLabel.font = .preferredFont( + forTextStyle: .subheadline, compatibleWith: self.traitCollection) + self.view.setNeedsUpdateConstraints() + self.view.updateConstraintsIfNeeded() + } + } - self.view.backgroundColor = .white + let viewController = MyViewController() + + #if os(iOS) + assertSnapshot(of: viewController, as: .image(on: .iPhoneSe), named: "iphone-se") + assertSnapshot(of: viewController, as: .image(on: .iPhone8), named: "iphone-8") + assertSnapshot(of: viewController, as: .image(on: .iPhone8Plus), named: "iphone-8-plus") + assertSnapshot(of: viewController, as: .image(on: .iPhoneX), named: "iphone-x") + assertSnapshot(of: viewController, as: .image(on: .iPhoneXr), named: "iphone-xr") + assertSnapshot(of: viewController, as: .image(on: .iPhoneXsMax), named: "iphone-xs-max") + assertSnapshot(of: viewController, as: .image(on: .iPadMini), named: "ipad-mini") + assertSnapshot(of: viewController, as: .image(on: .iPad9_7), named: "ipad-9-7") + assertSnapshot(of: viewController, as: .image(on: .iPad10_2), named: "ipad-10-2") + assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5), named: "ipad-pro-10-5") + assertSnapshot(of: viewController, as: .image(on: .iPadPro11), named: "ipad-pro-11") + assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9), named: "ipad-pro-12-9") - self.topLabel.text = "What's" - self.leadingLabel.text = "the" - self.trailingLabel.text = "point" - self.bottomLabel.text = "?" + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPhoneSe), named: "iphone-se") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPhone8), named: "iphone-8") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPhone8Plus), named: "iphone-8-plus") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPhoneX), named: "iphone-x") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPhoneXr), named: "iphone-xr") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPhoneXsMax), named: "iphone-xs-max") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPadMini), named: "ipad-mini") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPad9_7), named: "ipad-9-7") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPad10_2), named: "ipad-10-2") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPadPro10_5), named: "ipad-pro-10-5") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPadPro11), named: "ipad-pro-11") + assertSnapshot( + of: viewController, as: .recursiveDescription(on: .iPadPro12_9), named: "ipad-pro-12-9") - self.topLabel.translatesAutoresizingMaskIntoConstraints = false - self.leadingLabel.translatesAutoresizingMaskIntoConstraints = false - self.trailingLabel.translatesAutoresizingMaskIntoConstraints = false - self.bottomLabel.translatesAutoresizingMaskIntoConstraints = false + assertSnapshot( + of: viewController, as: .image(on: .iPhoneSe(.portrait)), named: "iphone-se") + assertSnapshot(of: viewController, as: .image(on: .iPhone8(.portrait)), named: "iphone-8") + assertSnapshot( + of: viewController, as: .image(on: .iPhone8Plus(.portrait)), named: "iphone-8-plus") + assertSnapshot(of: viewController, as: .image(on: .iPhoneX(.portrait)), named: "iphone-x") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneXr(.portrait)), named: "iphone-xr") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneXsMax(.portrait)), named: "iphone-xs-max") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.landscape)), named: "ipad-mini") + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.landscape)), named: "ipad-9-7") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.landscape)), named: "ipad-10-2") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.landscape)), named: "ipad-pro-10-5") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.landscape)), named: "ipad-pro-11") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.landscape)), named: "ipad-pro-12-9") - self.view.addSubview(self.topLabel) - self.view.addSubview(self.leadingLabel) - self.view.addSubview(self.trailingLabel) - self.view.addSubview(self.bottomLabel) + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.landscape(splitView: .oneThird))), + named: "ipad-mini-33-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.landscape(splitView: .oneHalf))), + named: "ipad-mini-50-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.landscape(splitView: .twoThirds))), + named: "ipad-mini-66-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.portrait(splitView: .oneThird))), + named: "ipad-mini-33-split-portrait") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.portrait(splitView: .twoThirds))), + named: "ipad-mini-66-split-portrait") - NSLayoutConstraint.activate([ - self.topLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), - self.topLabel.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - self.leadingLabel.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), - self.leadingLabel.trailingAnchor.constraint(lessThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), -// self.leadingLabel.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - self.leadingLabel.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), - self.trailingLabel.leadingAnchor.constraint(greaterThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), - self.trailingLabel.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor), - self.trailingLabel.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), - self.bottomLabel.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), - self.bottomLabel.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - ]) - } + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.landscape(splitView: .oneThird))), + named: "ipad-9-7-33-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.landscape(splitView: .oneHalf))), + named: "ipad-9-7-50-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.landscape(splitView: .twoThirds))), + named: "ipad-9-7-66-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.portrait(splitView: .oneThird))), + named: "ipad-9-7-33-split-portrait") + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.portrait(splitView: .twoThirds))), + named: "ipad-9-7-66-split-portrait") - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - self.topLabel.font = .preferredFont(forTextStyle: .headline, compatibleWith: self.traitCollection) - self.leadingLabel.font = .preferredFont(forTextStyle: .body, compatibleWith: self.traitCollection) - self.trailingLabel.font = .preferredFont(forTextStyle: .body, compatibleWith: self.traitCollection) - self.bottomLabel.font = .preferredFont(forTextStyle: .subheadline, compatibleWith: self.traitCollection) - self.view.setNeedsUpdateConstraints() - self.view.updateConstraintsIfNeeded() - } - } + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.landscape(splitView: .oneThird))), + named: "ipad-10-2-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.landscape(splitView: .oneHalf))), + named: "ipad-10-2-50-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.landscape(splitView: .twoThirds))), + named: "ipad-10-2-66-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.portrait(splitView: .oneThird))), + named: "ipad-10-2-33-split-portrait") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.portrait(splitView: .twoThirds))), + named: "ipad-10-2-66-split-portrait") - let viewController = MyViewController() + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.landscape(splitView: .oneThird))), + named: "ipad-pro-10inch-33-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.landscape(splitView: .oneHalf))), + named: "ipad-pro-10inch-50-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.landscape(splitView: .twoThirds))), + named: "ipad-pro-10inch-66-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.portrait(splitView: .oneThird))), + named: "ipad-pro-10inch-33-split-portrait") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.portrait(splitView: .twoThirds))), + named: "ipad-pro-10inch-66-split-portrait") - #if os(iOS) - assertSnapshot(of: viewController, as: .image(on: .iPhoneSe), named: "iphone-se") - assertSnapshot(of: viewController, as: .image(on: .iPhone8), named: "iphone-8") - assertSnapshot(of: viewController, as: .image(on: .iPhone8Plus), named: "iphone-8-plus") - assertSnapshot(of: viewController, as: .image(on: .iPhoneX), named: "iphone-x") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXr), named: "iphone-xr") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXsMax), named: "iphone-xs-max") - assertSnapshot(of: viewController, as: .image(on: .iPadMini), named: "ipad-mini") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7), named: "ipad-9-7") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2), named: "ipad-10-2") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5), named: "ipad-pro-10-5") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11), named: "ipad-pro-11") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9), named: "ipad-pro-12-9") - - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPhoneSe), named: "iphone-se") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPhone8), named: "iphone-8") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPhone8Plus), named: "iphone-8-plus") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPhoneX), named: "iphone-x") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPhoneXr), named: "iphone-xr") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPhoneXsMax), named: "iphone-xs-max") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPadMini), named: "ipad-mini") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPad9_7), named: "ipad-9-7") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPad10_2), named: "ipad-10-2") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPadPro10_5), named: "ipad-pro-10-5") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPadPro11), named: "ipad-pro-11") - assertSnapshot(of: viewController, as: .recursiveDescription(on: .iPadPro12_9), named: "ipad-pro-12-9") - - assertSnapshot(of: viewController, as: .image(on: .iPhoneSe(.portrait)), named: "iphone-se") - assertSnapshot(of: viewController, as: .image(on: .iPhone8(.portrait)), named: "iphone-8") - assertSnapshot(of: viewController, as: .image(on: .iPhone8Plus(.portrait)), named: "iphone-8-plus") - assertSnapshot(of: viewController, as: .image(on: .iPhoneX(.portrait)), named: "iphone-x") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXr(.portrait)), named: "iphone-xr") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXsMax(.portrait)), named: "iphone-xs-max") - assertSnapshot(of: viewController, as: .image(on: .iPadMini(.landscape)), named: "ipad-mini") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.landscape)), named: "ipad-9-7") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2(.landscape)), named: "ipad-10-2") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5(.landscape)), named: "ipad-pro-10-5") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11(.landscape)), named: "ipad-pro-11") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9(.landscape)), named: "ipad-pro-12-9") - - assertSnapshot(of: viewController, as: .image(on: .iPadMini(.landscape(splitView: .oneThird))), named: "ipad-mini-33-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadMini(.landscape(splitView: .oneHalf))), named: "ipad-mini-50-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadMini(.landscape(splitView: .twoThirds))), named: "ipad-mini-66-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadMini(.portrait(splitView: .oneThird))), named: "ipad-mini-33-split-portrait") - assertSnapshot(of: viewController, as: .image(on: .iPadMini(.portrait(splitView: .twoThirds))), named: "ipad-mini-66-split-portrait") - - assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.landscape(splitView: .oneThird))), named: "ipad-9-7-33-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.landscape(splitView: .oneHalf))), named: "ipad-9-7-50-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.landscape(splitView: .twoThirds))), named: "ipad-9-7-66-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.portrait(splitView: .oneThird))), named: "ipad-9-7-33-split-portrait") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.portrait(splitView: .twoThirds))), named: "ipad-9-7-66-split-portrait") - - assertSnapshot(of: viewController, as: .image(on: .iPad10_2(.landscape(splitView: .oneThird))), named: "ipad-10-2-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2(.landscape(splitView: .oneHalf))), named: "ipad-10-2-50-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2(.landscape(splitView: .twoThirds))), named: "ipad-10-2-66-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2(.portrait(splitView: .oneThird))), named: "ipad-10-2-33-split-portrait") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2(.portrait(splitView: .twoThirds))), named: "ipad-10-2-66-split-portrait") - - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5(.landscape(splitView: .oneThird))), named: "ipad-pro-10inch-33-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5(.landscape(splitView: .oneHalf))), named: "ipad-pro-10inch-50-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5(.landscape(splitView: .twoThirds))), named: "ipad-pro-10inch-66-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5(.portrait(splitView: .oneThird))), named: "ipad-pro-10inch-33-split-portrait") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5(.portrait(splitView: .twoThirds))), named: "ipad-pro-10inch-66-split-portrait") - - assertSnapshot(of: viewController, as: .image(on: .iPadPro11(.landscape(splitView: .oneThird))), named: "ipad-pro-11inch-33-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11(.landscape(splitView: .oneHalf))), named: "ipad-pro-11inch-50-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11(.landscape(splitView: .twoThirds))), named: "ipad-pro-11inch-66-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11(.portrait(splitView: .oneThird))), named: "ipad-pro-11inch-33-split-portrait") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11(.portrait(splitView: .twoThirds))), named: "ipad-pro-11inch-66-split-portrait") - - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9(.landscape(splitView: .oneThird))), named: "ipad-pro-12inch-33-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9(.landscape(splitView: .oneHalf))), named: "ipad-pro-12inch-50-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9(.landscape(splitView: .twoThirds))), named: "ipad-pro-12inch-66-split-landscape") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9(.portrait(splitView: .oneThird))), named: "ipad-pro-12inch-33-split-portrait") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9(.portrait(splitView: .twoThirds))), named: "ipad-pro-12inch-66-split-portrait") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.landscape(splitView: .oneThird))), + named: "ipad-pro-11inch-33-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.landscape(splitView: .oneHalf))), + named: "ipad-pro-11inch-50-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.landscape(splitView: .twoThirds))), + named: "ipad-pro-11inch-66-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.portrait(splitView: .oneThird))), + named: "ipad-pro-11inch-33-split-portrait") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.portrait(splitView: .twoThirds))), + named: "ipad-pro-11inch-66-split-portrait") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneSe(.landscape)), named: "iphone-se-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhone8(.landscape)), named: "iphone-8-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhone8Plus(.landscape)), named: "iphone-8-plus-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneX(.landscape)), named: "iphone-x-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneXr(.landscape)), named: "iphone-xr-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneXsMax(.landscape)), named: "iphone-xs-max-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadMini(.portrait)), named: "ipad-mini-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPad9_7(.portrait)), named: "ipad-9-7-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPad10_2(.portrait)), named: "ipad-10-2-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadPro10_5(.portrait)), named: "ipad-pro-10-5-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadPro11(.portrait)), named: "ipad-pro-11-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadPro12_9(.portrait)), named: "ipad-pro-12-9-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.landscape(splitView: .oneThird))), + named: "ipad-pro-12inch-33-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.landscape(splitView: .oneHalf))), + named: "ipad-pro-12inch-50-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.landscape(splitView: .twoThirds))), + named: "ipad-pro-12inch-66-split-landscape") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.portrait(splitView: .oneThird))), + named: "ipad-pro-12inch-33-split-portrait") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.portrait(splitView: .twoThirds))), + named: "ipad-pro-12inch-66-split-portrait") - allContentSizes.forEach { name, contentSize in assertSnapshot( - of: viewController, - as: .image(on: .iPhoneSe, traits: .init(preferredContentSizeCategory: contentSize)), - named: "iphone-se-\(name)" + of: viewController, as: .image(on: .iPhoneSe(.landscape)), + named: "iphone-se-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhone8(.landscape)), named: "iphone-8-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhone8Plus(.landscape)), + named: "iphone-8-plus-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneX(.landscape)), named: "iphone-x-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneXr(.landscape)), + named: "iphone-xr-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneXsMax(.landscape)), + named: "iphone-xs-max-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.portrait)), named: "ipad-mini-alternative" ) + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.portrait)), named: "ipad-9-7-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.portrait)), named: "ipad-10-2-alternative" + ) + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.portrait)), + named: "ipad-pro-10-5-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.portrait)), + named: "ipad-pro-11-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.portrait)), + named: "ipad-pro-12-9-alternative") + + allContentSizes.forEach { name, contentSize in + assertSnapshot( + of: viewController, + as: .image(on: .iPhoneSe, traits: .init(preferredContentSizeCategory: contentSize)), + named: "iphone-se-\(name)" + ) + } + #elseif os(tvOS) + assertSnapshot( + of: viewController, as: .image(on: .tv), named: "tv") + assertSnapshot( + of: viewController, as: .image(on: .tv4K), named: "tv4k") + #endif } - #elseif os(tvOS) - assertSnapshot( - of: viewController, as: .image(on: .tv), named: "tv") - assertSnapshot( - of: viewController, as: .image(on: .tv4K), named: "tv4k") - #endif - } #endif } func testTraitsEmbeddedInTabNavigation() { #if os(iOS) - if #available(iOS 11.0, *) { - class MyViewController: UIViewController { - let topLabel = UILabel() - let leadingLabel = UILabel() - let trailingLabel = UILabel() - let bottomLabel = UILabel() - - override func viewDidLoad() { - super.viewDidLoad() - - self.navigationItem.leftBarButtonItem = .init(barButtonSystemItem: .add, target: nil, action: nil) - - self.view.backgroundColor = .white - - self.topLabel.text = "What's" - self.leadingLabel.text = "the" - self.trailingLabel.text = "point" - self.bottomLabel.text = "?" - - self.topLabel.translatesAutoresizingMaskIntoConstraints = false - self.leadingLabel.translatesAutoresizingMaskIntoConstraints = false - self.trailingLabel.translatesAutoresizingMaskIntoConstraints = false - self.bottomLabel.translatesAutoresizingMaskIntoConstraints = false - - self.view.addSubview(self.topLabel) - self.view.addSubview(self.leadingLabel) - self.view.addSubview(self.trailingLabel) - self.view.addSubview(self.bottomLabel) - - NSLayoutConstraint.activate([ - self.topLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), - self.topLabel.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - self.leadingLabel.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), - self.leadingLabel.trailingAnchor.constraint(lessThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), -// self.leadingLabel.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - self.leadingLabel.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), - self.trailingLabel.leadingAnchor.constraint(greaterThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), - self.trailingLabel.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor), - self.trailingLabel.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), - self.bottomLabel.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), - self.bottomLabel.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), + if #available(iOS 11.0, *) { + class MyViewController: UIViewController { + let topLabel = UILabel() + let leadingLabel = UILabel() + let trailingLabel = UILabel() + let bottomLabel = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + + self.navigationItem.leftBarButtonItem = .init( + barButtonSystemItem: .add, target: nil, action: nil) + + self.view.backgroundColor = .white + + self.topLabel.text = "What's" + self.leadingLabel.text = "the" + self.trailingLabel.text = "point" + self.bottomLabel.text = "?" + + self.topLabel.translatesAutoresizingMaskIntoConstraints = false + self.leadingLabel.translatesAutoresizingMaskIntoConstraints = false + self.trailingLabel.translatesAutoresizingMaskIntoConstraints = false + self.bottomLabel.translatesAutoresizingMaskIntoConstraints = false + + self.view.addSubview(self.topLabel) + self.view.addSubview(self.leadingLabel) + self.view.addSubview(self.trailingLabel) + self.view.addSubview(self.bottomLabel) + + NSLayoutConstraint.activate([ + self.topLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + self.topLabel.centerXAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), + self.leadingLabel.leadingAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), + self.leadingLabel.trailingAnchor.constraint( + lessThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), + // self.leadingLabel.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), + self.leadingLabel.centerYAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), + self.trailingLabel.leadingAnchor.constraint( + greaterThanOrEqualTo: self.view.safeAreaLayoutGuide.centerXAnchor), + self.trailingLabel.trailingAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.trailingAnchor), + self.trailingLabel.centerYAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), + self.bottomLabel.bottomAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + self.bottomLabel.centerXAnchor.constraint( + equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), ]) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + self.topLabel.font = .preferredFont( + forTextStyle: .headline, compatibleWith: self.traitCollection) + self.leadingLabel.font = .preferredFont( + forTextStyle: .body, compatibleWith: self.traitCollection) + self.trailingLabel.font = .preferredFont( + forTextStyle: .body, compatibleWith: self.traitCollection) + self.bottomLabel.font = .preferredFont( + forTextStyle: .subheadline, compatibleWith: self.traitCollection) + self.view.setNeedsUpdateConstraints() + self.view.updateConstraintsIfNeeded() + } } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - self.topLabel.font = .preferredFont(forTextStyle: .headline, compatibleWith: self.traitCollection) - self.leadingLabel.font = .preferredFont(forTextStyle: .body, compatibleWith: self.traitCollection) - self.trailingLabel.font = .preferredFont(forTextStyle: .body, compatibleWith: self.traitCollection) - self.bottomLabel.font = .preferredFont(forTextStyle: .subheadline, compatibleWith: self.traitCollection) - self.view.setNeedsUpdateConstraints() - self.view.updateConstraintsIfNeeded() - } - } - - let myViewController = MyViewController() - let navController = UINavigationController(rootViewController: myViewController) - let viewController = UITabBarController() - viewController.setViewControllers([navController], animated: false) - - assertSnapshot(of: viewController, as: .image(on: .iPhoneSe), named: "iphone-se") - assertSnapshot(of: viewController, as: .image(on: .iPhone8), named: "iphone-8") - assertSnapshot(of: viewController, as: .image(on: .iPhone8Plus), named: "iphone-8-plus") - assertSnapshot(of: viewController, as: .image(on: .iPhoneX), named: "iphone-x") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXr), named: "iphone-xr") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXsMax), named: "iphone-xs-max") - assertSnapshot(of: viewController, as: .image(on: .iPadMini), named: "ipad-mini") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7), named: "ipad-9-7") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2), named: "ipad-10-2") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5), named: "ipad-pro-10-5") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11), named: "ipad-pro-11") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9), named: "ipad-pro-12-9") - - assertSnapshot(of: viewController, as: .image(on: .iPhoneSe(.portrait)), named: "iphone-se") - assertSnapshot(of: viewController, as: .image(on: .iPhone8(.portrait)), named: "iphone-8") - assertSnapshot(of: viewController, as: .image(on: .iPhone8Plus(.portrait)), named: "iphone-8-plus") - assertSnapshot(of: viewController, as: .image(on: .iPhoneX(.portrait)), named: "iphone-x") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXr(.portrait)), named: "iphone-xr") - assertSnapshot(of: viewController, as: .image(on: .iPhoneXsMax(.portrait)), named: "iphone-xs-max") - assertSnapshot(of: viewController, as: .image(on: .iPadMini(.landscape)), named: "ipad-mini") - assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.landscape)), named: "ipad-9-7") - assertSnapshot(of: viewController, as: .image(on: .iPad10_2(.landscape)), named: "ipad-10-2") - assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5(.landscape)), named: "ipad-pro-10-5") - assertSnapshot(of: viewController, as: .image(on: .iPadPro11(.landscape)), named: "ipad-pro-11") - assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9(.landscape)), named: "ipad-pro-12-9") + let myViewController = MyViewController() + let navController = UINavigationController(rootViewController: myViewController) + let viewController = UITabBarController() + viewController.setViewControllers([navController], animated: false) + + assertSnapshot(of: viewController, as: .image(on: .iPhoneSe), named: "iphone-se") + assertSnapshot(of: viewController, as: .image(on: .iPhone8), named: "iphone-8") + assertSnapshot(of: viewController, as: .image(on: .iPhone8Plus), named: "iphone-8-plus") + assertSnapshot(of: viewController, as: .image(on: .iPhoneX), named: "iphone-x") + assertSnapshot(of: viewController, as: .image(on: .iPhoneXr), named: "iphone-xr") + assertSnapshot(of: viewController, as: .image(on: .iPhoneXsMax), named: "iphone-xs-max") + assertSnapshot(of: viewController, as: .image(on: .iPadMini), named: "ipad-mini") + assertSnapshot(of: viewController, as: .image(on: .iPad9_7), named: "ipad-9-7") + assertSnapshot(of: viewController, as: .image(on: .iPad10_2), named: "ipad-10-2") + assertSnapshot(of: viewController, as: .image(on: .iPadPro10_5), named: "ipad-pro-10-5") + assertSnapshot(of: viewController, as: .image(on: .iPadPro11), named: "ipad-pro-11") + assertSnapshot(of: viewController, as: .image(on: .iPadPro12_9), named: "ipad-pro-12-9") + + assertSnapshot(of: viewController, as: .image(on: .iPhoneSe(.portrait)), named: "iphone-se") + assertSnapshot(of: viewController, as: .image(on: .iPhone8(.portrait)), named: "iphone-8") + assertSnapshot( + of: viewController, as: .image(on: .iPhone8Plus(.portrait)), named: "iphone-8-plus") + assertSnapshot(of: viewController, as: .image(on: .iPhoneX(.portrait)), named: "iphone-x") + assertSnapshot(of: viewController, as: .image(on: .iPhoneXr(.portrait)), named: "iphone-xr") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneXsMax(.portrait)), named: "iphone-xs-max") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.landscape)), named: "ipad-mini") + assertSnapshot(of: viewController, as: .image(on: .iPad9_7(.landscape)), named: "ipad-9-7") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.landscape)), named: "ipad-10-2") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.landscape)), named: "ipad-pro-10-5") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.landscape)), named: "ipad-pro-11") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.landscape)), named: "ipad-pro-12-9") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneSe(.landscape)), named: "iphone-se-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhone8(.landscape)), named: "iphone-8-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhone8Plus(.landscape)), named: "iphone-8-plus-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneX(.landscape)), named: "iphone-x-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneXr(.landscape)), named: "iphone-xr-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPhoneXsMax(.landscape)), named: "iphone-xs-max-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadMini(.portrait)), named: "ipad-mini-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPad9_7(.portrait)), named: "ipad-9-7-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPad10_2(.portrait)), named: "ipad-10-2-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadPro10_5(.portrait)), named: "ipad-pro-10-5-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadPro11(.portrait)), named: "ipad-pro-11-alternative") - assertSnapshot( - of: viewController, as: .image(on: .iPadPro12_9(.portrait)), named: "ipad-pro-12-9-alternative") - } + assertSnapshot( + of: viewController, as: .image(on: .iPhoneSe(.landscape)), named: "iphone-se-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhone8(.landscape)), named: "iphone-8-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhone8Plus(.landscape)), + named: "iphone-8-plus-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneX(.landscape)), named: "iphone-x-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneXr(.landscape)), named: "iphone-xr-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPhoneXsMax(.landscape)), + named: "iphone-xs-max-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadMini(.portrait)), named: "ipad-mini-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPad9_7(.portrait)), named: "ipad-9-7-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPad10_2(.portrait)), named: "ipad-10-2-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro10_5(.portrait)), + named: "ipad-pro-10-5-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro11(.portrait)), + named: "ipad-pro-11-alternative") + assertSnapshot( + of: viewController, as: .image(on: .iPadPro12_9(.portrait)), + named: "ipad-pro-12-9-alternative") + } #endif } func testCollectionViewsWithMultipleScreenSizes() { #if os(iOS) - final class CollectionViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - - let flowLayout: UICollectionViewFlowLayout = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.minimumLineSpacing = 20 - return layout - }() + final class CollectionViewController: UIViewController, UICollectionViewDataSource, + UICollectionViewDelegateFlowLayout + { - lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) + let flowLayout: UICollectionViewFlowLayout = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 20 + return layout + }() - override func viewDidLoad() { - super.viewDidLoad() + lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) - view.backgroundColor = .white - view.addSubview(collectionView) + override func viewDidLoad() { + super.viewDidLoad() - collectionView.backgroundColor = .white - collectionView.dataSource = self - collectionView.delegate = self - collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell") - collectionView.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .white + view.addSubview(collectionView) - NSLayoutConstraint.activate([ - collectionView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor) - ]) + collectionView.backgroundColor = .white + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell") + collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.reloadData() - } + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + collectionView.trailingAnchor.constraint( + equalTo: view.layoutMarginsGuide.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor), + ]) + + collectionView.reloadData() + } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - collectionView.collectionViewLayout.invalidateLayout() - } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + collectionView.collectionViewLayout.invalidateLayout() + } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - collectionView.collectionViewLayout.invalidateLayout() - } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + collectionView.collectionViewLayout.invalidateLayout() + } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) - cell.contentView.backgroundColor = .orange - return cell - } + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) + -> UICollectionViewCell + { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) + cell.contentView.backgroundColor = .orange + return cell + } - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return 20 - } + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) + -> Int + { + return 20 + } - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath ) -> CGSize { - return CGSize( - width: min(collectionView.frame.width - 50, 300), - height: collectionView.frame.height - ) - } + return CGSize( + width: min(collectionView.frame.width - 50, 300), + height: collectionView.frame.height + ) + } - } + } - let viewController = CollectionViewController() + let viewController = CollectionViewController() - assertSnapshots(of: viewController, as: [ - "ipad": .image(on: .iPadPro12_9), - "iphoneSe": .image(on: .iPhoneSe), - "iphone8": .image(on: .iPhone8), - "iphoneMax": .image(on: .iPhoneXsMax) - ]) + assertSnapshots( + of: viewController, + as: [ + "ipad": .image(on: .iPadPro12_9), + "iphoneSe": .image(on: .iPhoneSe), + "iphone8": .image(on: .iPhone8), + "iphoneMax": .image(on: .iPhoneXsMax), + ]) #endif } func testTraitsWithView() { #if os(iOS) - if #available(iOS 11.0, *) { + if #available(iOS 11.0, *) { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .title1) + label.adjustsFontForContentSizeCategory = true + label.text = "What's the point?" + + allContentSizes.forEach { name, contentSize in + assertSnapshot( + of: label, + as: .image(traits: .init(preferredContentSizeCategory: contentSize)), + named: "label-\(name)" + ) + } + } + #endif + } + + func testTraitsWithViewController() { + #if os(iOS) let label = UILabel() label.font = .preferredFont(forTextStyle: .title1) label.adjustsFontForContentSizeCategory = true label.text = "What's the point?" + let viewController = UIViewController() + viewController.view.addSubview(label) + + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint( + equalTo: viewController.view.layoutMarginsGuide.leadingAnchor), + label.topAnchor.constraint(equalTo: viewController.view.layoutMarginsGuide.topAnchor), + label.trailingAnchor.constraint( + equalTo: viewController.view.layoutMarginsGuide.trailingAnchor), + ]) + allContentSizes.forEach { name, contentSize in assertSnapshot( - of: label, - as: .image(traits: .init(preferredContentSizeCategory: contentSize)), + of: viewController, + as: .recursiveDescription( + on: .iPhoneSe, traits: .init(preferredContentSizeCategory: contentSize)), named: "label-\(name)" ) } - } - #endif - } - - func testTraitsWithViewController() { - #if os(iOS) - let label = UILabel() - label.font = .preferredFont(forTextStyle: .title1) - label.adjustsFontForContentSizeCategory = true - label.text = "What's the point?" - - let viewController = UIViewController() - viewController.view.addSubview(label) - - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: viewController.view.layoutMarginsGuide.leadingAnchor), - label.topAnchor.constraint(equalTo: viewController.view.layoutMarginsGuide.topAnchor), - label.trailingAnchor.constraint(equalTo: viewController.view.layoutMarginsGuide.trailingAnchor) - ]) - - allContentSizes.forEach { name, contentSize in - assertSnapshot( - of: viewController, - as: .recursiveDescription(on: .iPhoneSe, traits: .init(preferredContentSizeCategory: contentSize)), - named: "label-\(name)" - ) - } #endif } func testUIBezierPath() { #if os(iOS) || os(tvOS) - let path = UIBezierPath.heart + let path = UIBezierPath.heart - let osName: String - #if os(iOS) - osName = "iOS" - #elseif os(tvOS) - osName = "tvOS" - #endif + let osName: String + #if os(iOS) + osName = "iOS" + #elseif os(tvOS) + osName = "tvOS" + #endif - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot(of: path, as: .image, named: osName) - } + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot(of: path, as: .image, named: osName) + } - if #available(iOS 11.0, tvOS 11.0, *) { - assertSnapshot(of: path, as: .elementsDescription, named: osName) - } + if #available(iOS 11.0, tvOS 11.0, *) { + assertSnapshot(of: path, as: .elementsDescription, named: osName) + } #endif } func testUIView() { #if os(iOS) - let view = UIButton(type: .contactAdd) - assertSnapshot(of: view, as: .image) - assertSnapshot(of: view, as: .recursiveDescription) + let view = UIButton(type: .contactAdd) + assertSnapshot(of: view, as: .image) + assertSnapshot(of: view, as: .recursiveDescription) #endif } func testUIViewControllerLifeCycle() { #if os(iOS) - class ViewController: UIViewController { - let viewDidLoadExpectation: XCTestExpectation - let viewWillAppearExpectation: XCTestExpectation - let viewDidAppearExpectation: XCTestExpectation - let viewWillDisappearExpectation: XCTestExpectation - let viewDidDisappearExpectation: XCTestExpectation - init(viewDidLoadExpectation: XCTestExpectation, - viewWillAppearExpectation: XCTestExpectation, - viewDidAppearExpectation: XCTestExpectation, - viewWillDisappearExpectation: XCTestExpectation, - viewDidDisappearExpectation: XCTestExpectation){ - self.viewDidLoadExpectation = viewDidLoadExpectation - self.viewWillAppearExpectation = viewWillAppearExpectation - self.viewDidAppearExpectation = viewDidAppearExpectation - self.viewWillDisappearExpectation = viewWillDisappearExpectation - self.viewDidDisappearExpectation = viewDidDisappearExpectation - super.init(nibName: nil, bundle: nil) - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - override func viewDidLoad() { - super.viewDidLoad() - viewDidLoadExpectation.fulfill() - } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - viewWillAppearExpectation.fulfill() - } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - viewDidAppearExpectation.fulfill() - } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - viewWillDisappearExpectation.fulfill() - } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - viewDidDisappearExpectation.fulfill() + class ViewController: UIViewController { + let viewDidLoadExpectation: XCTestExpectation + let viewWillAppearExpectation: XCTestExpectation + let viewDidAppearExpectation: XCTestExpectation + let viewWillDisappearExpectation: XCTestExpectation + let viewDidDisappearExpectation: XCTestExpectation + init( + viewDidLoadExpectation: XCTestExpectation, + viewWillAppearExpectation: XCTestExpectation, + viewDidAppearExpectation: XCTestExpectation, + viewWillDisappearExpectation: XCTestExpectation, + viewDidDisappearExpectation: XCTestExpectation + ) { + self.viewDidLoadExpectation = viewDidLoadExpectation + self.viewWillAppearExpectation = viewWillAppearExpectation + self.viewDidAppearExpectation = viewDidAppearExpectation + self.viewWillDisappearExpectation = viewWillDisappearExpectation + self.viewDidDisappearExpectation = viewDidDisappearExpectation + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { + super.viewDidLoad() + viewDidLoadExpectation.fulfill() + } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewWillAppearExpectation.fulfill() + } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewDidAppearExpectation.fulfill() + } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + viewWillDisappearExpectation.fulfill() + } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + viewDidDisappearExpectation.fulfill() + } } - } - - let viewDidLoadExpectation = expectation(description: "viewDidLoad") - let viewWillAppearExpectation = expectation(description: "viewWillAppear") - let viewDidAppearExpectation = expectation(description: "viewDidAppear") - let viewWillDisappearExpectation = expectation(description: "viewWillDisappear") - let viewDidDisappearExpectation = expectation(description: "viewDidDisappear") - viewWillAppearExpectation.expectedFulfillmentCount = 4 - viewDidAppearExpectation.expectedFulfillmentCount = 4 - viewWillDisappearExpectation.expectedFulfillmentCount = 4 - viewDidDisappearExpectation.expectedFulfillmentCount = 4 - - let viewController = ViewController( - viewDidLoadExpectation: viewDidLoadExpectation, - viewWillAppearExpectation: viewWillAppearExpectation, - viewDidAppearExpectation: viewDidAppearExpectation, - viewWillDisappearExpectation: viewWillDisappearExpectation, - viewDidDisappearExpectation: viewDidDisappearExpectation - ) - assertSnapshot(of: viewController, as: .image) - assertSnapshot(of: viewController, as: .image) + let viewDidLoadExpectation = expectation(description: "viewDidLoad") + let viewWillAppearExpectation = expectation(description: "viewWillAppear") + let viewDidAppearExpectation = expectation(description: "viewDidAppear") + let viewWillDisappearExpectation = expectation(description: "viewWillDisappear") + let viewDidDisappearExpectation = expectation(description: "viewDidDisappear") + viewWillAppearExpectation.expectedFulfillmentCount = 4 + viewDidAppearExpectation.expectedFulfillmentCount = 4 + viewWillDisappearExpectation.expectedFulfillmentCount = 4 + viewDidDisappearExpectation.expectedFulfillmentCount = 4 + + let viewController = ViewController( + viewDidLoadExpectation: viewDidLoadExpectation, + viewWillAppearExpectation: viewWillAppearExpectation, + viewDidAppearExpectation: viewDidAppearExpectation, + viewWillDisappearExpectation: viewWillDisappearExpectation, + viewDidDisappearExpectation: viewDidDisappearExpectation + ) - wait(for: [ - viewDidLoadExpectation, - viewWillAppearExpectation, - viewDidAppearExpectation, - viewWillDisappearExpectation, - viewDidDisappearExpectation, - ], timeout: 1.0, enforceOrder: true) + assertSnapshot(of: viewController, as: .image) + assertSnapshot(of: viewController, as: .image) + + wait( + for: [ + viewDidLoadExpectation, + viewWillAppearExpectation, + viewDidAppearExpectation, + viewWillDisappearExpectation, + viewDidDisappearExpectation, + ], timeout: 1.0, enforceOrder: true) #endif } func testCALayer() { #if os(iOS) - let layer = CALayer() - layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - layer.backgroundColor = UIColor.red.cgColor - layer.borderWidth = 4.0 - layer.borderColor = UIColor.black.cgColor - assertSnapshot(of: layer, as: .image) + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + layer.backgroundColor = UIColor.red.cgColor + layer.borderWidth = 4.0 + layer.borderColor = UIColor.black.cgColor + assertSnapshot(of: layer, as: .image) #endif } func testCALayerWithGradient() { #if os(iOS) - let baseLayer = CALayer() - baseLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - let gradientLayer = CAGradientLayer() - gradientLayer.colors = [UIColor.red.cgColor, UIColor.yellow.cgColor] - gradientLayer.frame = baseLayer.frame - baseLayer.addSublayer(gradientLayer) - assertSnapshot(of: baseLayer, as: .image) + let baseLayer = CALayer() + baseLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.red.cgColor, UIColor.yellow.cgColor] + gradientLayer.frame = baseLayer.frame + baseLayer.addSublayer(gradientLayer) + assertSnapshot(of: baseLayer, as: .image) #endif } func testViewControllerHierarchy() { #if os(iOS) - let page = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal) - page.setViewControllers([UIViewController()], direction: .forward, animated: false) - let tab = UITabBarController() - tab.viewControllers = [ - UINavigationController(rootViewController: page), - UINavigationController(rootViewController: UIViewController()), - UINavigationController(rootViewController: UIViewController()), - UINavigationController(rootViewController: UIViewController()), - UINavigationController(rootViewController: UIViewController()) - ] - assertSnapshot(of: tab, as: .hierarchy) + let page = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal) + page.setViewControllers([UIViewController()], direction: .forward, animated: false) + let tab = UITabBarController() + tab.viewControllers = [ + UINavigationController(rootViewController: page), + UINavigationController(rootViewController: UIViewController()), + UINavigationController(rootViewController: UIViewController()), + UINavigationController(rootViewController: UIViewController()), + UINavigationController(rootViewController: UIViewController()), + ] + assertSnapshot(of: tab, as: .hierarchy) #endif } @@ -977,7 +1087,8 @@ final class SnapshotTestingTests: XCTestCase { assertSnapshot(of: get, as: .raw, named: "get") assertSnapshot(of: get, as: .curl, named: "get-curl") - var getWithQuery = URLRequest(url: URL(string: "https://www.pointfree.co?key_2=value_2&key_1=value_1&key_3=value_3")!) + var getWithQuery = URLRequest( + url: URL(string: "https://www.pointfree.co?key_2=value_2&key_1=value_1&key_3=value_3")!) getWithQuery.addValue("pf_session={}", forHTTPHeaderField: "Cookie") getWithQuery.addValue("text/html", forHTTPHeaderField: "Accept") getWithQuery.addValue("application/json", forHTTPHeaderField: "Content-Type") @@ -992,11 +1103,13 @@ final class SnapshotTestingTests: XCTestCase { assertSnapshot(of: post, as: .raw, named: "post") assertSnapshot(of: post, as: .curl, named: "post-curl") - var postWithJSON = URLRequest(url: URL(string: "http://dummy.restapiexample.com/api/v1/create")!) + var postWithJSON = URLRequest( + url: URL(string: "http://dummy.restapiexample.com/api/v1/create")!) postWithJSON.httpMethod = "POST" postWithJSON.addValue("application/json", forHTTPHeaderField: "Content-Type") postWithJSON.addValue("application/json", forHTTPHeaderField: "Accept") - postWithJSON.httpBody = Data("{\"name\":\"tammy134235345235\", \"salary\":0, \"age\":\"tammy133\"}".utf8) + postWithJSON.httpBody = Data( + "{\"name\":\"tammy134235345235\", \"salary\":0, \"age\":\"tammy133\"}".utf8) assertSnapshot(of: postWithJSON, as: .raw, named: "post-with-json") assertSnapshot(of: postWithJSON, as: .curl, named: "post-with-json-curl") @@ -1010,199 +1123,198 @@ final class SnapshotTestingTests: XCTestCase { post.httpMethod = "POST" post.addValue("pf_session={\"user_id\":\"0\"}", forHTTPHeaderField: "Cookie") post.addValue("application/json", forHTTPHeaderField: "Accept") - post.httpBody = Data(""" - {"pricing": {"lane": "individual","billing": "monthly"}} - """.utf8) - _assertInlineSnapshot(matching: post, as: .raw(pretty: true), with: """ - POST https://www.pointfree.co/subscribe - Accept: application/json - Cookie: pf_session={"user_id":"0"} - - { - "pricing" : { - "billing" : "monthly", - "lane" : "individual" - } - } - """) + post.httpBody = Data( + """ + {"pricing": {"lane": "individual","billing": "monthly"}} + """.utf8) } func testWebView() throws { #if os(iOS) || os(macOS) - let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) - .deletingLastPathComponent() - .appendingPathComponent("__Fixtures__/pointfree.html") - let html = try String(contentsOf: fixtureUrl) - let webView = WKWebView() - webView.loadHTMLString(html, baseURL: nil) - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot( - of: webView, - as: .image(size: .init(width: 800, height: 600)), - named: platform - ) - } + let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) + .deletingLastPathComponent() + .appendingPathComponent("__Fixtures__/pointfree.html") + let html = try String(contentsOf: fixtureUrl) + let webView = WKWebView() + webView.loadHTMLString(html, baseURL: nil) + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot( + of: webView, + as: .image(size: .init(width: 800, height: 600)), + named: platform + ) + } #endif } func testViewWithZeroHeightOrWidth() { #if os(iOS) || os(tvOS) - var rect = CGRect(x: 0, y: 0, width: 350, height: 0) - var view = UIView(frame: rect) - view.backgroundColor = .red - assertSnapshot(of: view, as: .image, named: "noHeight") - - rect = CGRect(x: 0, y: 0, width: 0, height: 350) - view = UIView(frame: rect) - view.backgroundColor = .green - assertSnapshot(of: view, as: .image, named: "noWidth") - - rect = CGRect(x: 0, y: 0, width: 0, height: 0) - view = UIView(frame: rect) - view.backgroundColor = .blue - assertSnapshot(of: view, as: .image, named: "noWidth.noHeight") + var rect = CGRect(x: 0, y: 0, width: 350, height: 0) + var view = UIView(frame: rect) + view.backgroundColor = .red + assertSnapshot(of: view, as: .image, named: "noHeight") + + rect = CGRect(x: 0, y: 0, width: 0, height: 350) + view = UIView(frame: rect) + view.backgroundColor = .green + assertSnapshot(of: view, as: .image, named: "noWidth") + + rect = CGRect(x: 0, y: 0, width: 0, height: 0) + view = UIView(frame: rect) + view.backgroundColor = .blue + assertSnapshot(of: view, as: .image, named: "noWidth.noHeight") #endif } func testViewAgainstEmptyImage() { #if os(iOS) || os(tvOS) - let rect = CGRect(x: 0, y: 0, width: 0, height: 0) - let view = UIView(frame: rect) - view.backgroundColor = .blue + let rect = CGRect(x: 0, y: 0, width: 0, height: 0) + let view = UIView(frame: rect) + view.backgroundColor = .blue - let failure = verifySnapshot(of: view, as: .image, named: "notEmptyImage") - XCTAssertNotNil(failure) + let failure = verifySnapshot(of: view, as: .image, named: "notEmptyImage") + XCTAssertNotNil(failure) #endif } func testEmbeddedWebView() throws { #if os(iOS) - let label = UILabel() - label.text = "Hello, Blob!" + let label = UILabel() + label.text = "Hello, Blob!" - let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) - .deletingLastPathComponent() - .appendingPathComponent("__Fixtures__/pointfree.html") - let html = try String(contentsOf: fixtureUrl) - let webView = WKWebView() - webView.loadHTMLString(html, baseURL: nil) - webView.isHidden = true + let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) + .deletingLastPathComponent() + .appendingPathComponent("__Fixtures__/pointfree.html") + let html = try String(contentsOf: fixtureUrl) + let webView = WKWebView() + webView.loadHTMLString(html, baseURL: nil) + webView.isHidden = true - let stackView = UIStackView(arrangedSubviews: [label, webView]) - stackView.axis = .vertical + let stackView = UIStackView(arrangedSubviews: [label, webView]) + stackView.axis = .vertical - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot( - of: stackView, - as: .image(size: .init(width: 800, height: 600)), - named: platform - ) - } + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot( + of: stackView, + as: .image(size: .init(width: 800, height: 600)), + named: platform + ) + } #endif } #if os(iOS) || os(macOS) - final class ManipulatingWKWebViewNavigationDelegate: NSObject, WKNavigationDelegate { - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.evaluateJavaScript("document.body.children[0].classList.remove(\"hero\")") // Change layout + final class ManipulatingWKWebViewNavigationDelegate: NSObject, WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript("document.body.children[0].classList.remove(\"hero\")") // Change layout + } } - } - func testWebViewWithManipulatingNavigationDelegate() throws { - let manipulatingWKWebViewNavigationDelegate = ManipulatingWKWebViewNavigationDelegate() - let webView = WKWebView() - webView.navigationDelegate = manipulatingWKWebViewNavigationDelegate - - let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) - .deletingLastPathComponent() - .appendingPathComponent("__Fixtures__/pointfree.html") - let html = try String(contentsOf: fixtureUrl) - webView.loadHTMLString(html, baseURL: nil) - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot( - of: webView, - as: .image(size: .init(width: 800, height: 600)), - named: platform - ) + func testWebViewWithManipulatingNavigationDelegate() throws { + let manipulatingWKWebViewNavigationDelegate = ManipulatingWKWebViewNavigationDelegate() + let webView = WKWebView() + webView.navigationDelegate = manipulatingWKWebViewNavigationDelegate + + let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) + .deletingLastPathComponent() + .appendingPathComponent("__Fixtures__/pointfree.html") + let html = try String(contentsOf: fixtureUrl) + webView.loadHTMLString(html, baseURL: nil) + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot( + of: webView, + as: .image(size: .init(width: 800, height: 600)), + named: platform + ) + } + _ = manipulatingWKWebViewNavigationDelegate } - _ = manipulatingWKWebViewNavigationDelegate - } - final class CancellingWKWebViewNavigationDelegate: NSObject, WKNavigationDelegate { - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - decisionHandler(.cancel) + final class CancellingWKWebViewNavigationDelegate: NSObject, WKNavigationDelegate { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + decisionHandler(.cancel) + } } - } - - func testWebViewWithCancellingNavigationDelegate() throws { - let cancellingWKWebViewNavigationDelegate = CancellingWKWebViewNavigationDelegate() - let webView = WKWebView() - webView.navigationDelegate = cancellingWKWebViewNavigationDelegate - - let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) - .deletingLastPathComponent() - .appendingPathComponent("__Fixtures__/pointfree.html") - let html = try String(contentsOf: fixtureUrl) - webView.loadHTMLString(html, baseURL: nil) - if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { - assertSnapshot( - of: webView, - as: .image(size: .init(width: 800, height: 600)), - named: platform - ) + + func testWebViewWithCancellingNavigationDelegate() throws { + let cancellingWKWebViewNavigationDelegate = CancellingWKWebViewNavigationDelegate() + let webView = WKWebView() + webView.navigationDelegate = cancellingWKWebViewNavigationDelegate + + let fixtureUrl = URL(fileURLWithPath: String(#file), isDirectory: false) + .deletingLastPathComponent() + .appendingPathComponent("__Fixtures__/pointfree.html") + let html = try String(contentsOf: fixtureUrl) + webView.loadHTMLString(html, baseURL: nil) + if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") { + assertSnapshot( + of: webView, + as: .image(size: .init(width: 800, height: 600)), + named: platform + ) + } + _ = cancellingWKWebViewNavigationDelegate } - _ = cancellingWKWebViewNavigationDelegate - } #endif #if os(iOS) - @available(iOS 13.0, *) - func testSwiftUIView_iOS() { - struct MyView: SwiftUI.View { - var body: some SwiftUI.View { - HStack { - Image(systemName: "checkmark.circle.fill") + @available(iOS 13.0, *) + func testSwiftUIView_iOS() { + struct MyView: SwiftUI.View { + var body: some SwiftUI.View { + HStack { + Image(systemName: "checkmark.circle.fill") Text("Checked").fixedSize() + } + .padding(5) + .background(RoundedRectangle(cornerRadius: 5.0).fill(Color.blue)) + .padding(10) } - .padding(5) - .background(RoundedRectangle(cornerRadius: 5.0).fill(Color.blue)) - .padding(10) } - } - let view = MyView().background(Color.yellow) + let view = MyView().background(Color.yellow) - assertSnapshot(of: view, as: .image(traits: .init(userInterfaceStyle: .light))) - assertSnapshot(of: view, as: .image(layout: .sizeThatFits, traits: .init(userInterfaceStyle: .light)), named: "size-that-fits") - assertSnapshot(of: view, as: .image(layout: .fixed(width: 200.0, height: 100.0), traits: .init(userInterfaceStyle: .light)), named: "fixed") - assertSnapshot(of: view, as: .image(layout: .device(config: .iPhoneSe), traits: .init(userInterfaceStyle: .light)), named: "device") - } + assertSnapshot(of: view, as: .image(traits: .init(userInterfaceStyle: .light))) + assertSnapshot( + of: view, as: .image(layout: .sizeThatFits, traits: .init(userInterfaceStyle: .light)), + named: "size-that-fits") + assertSnapshot( + of: view, + as: .image( + layout: .fixed(width: 200.0, height: 100.0), traits: .init(userInterfaceStyle: .light)), + named: "fixed") + assertSnapshot( + of: view, + as: .image(layout: .device(config: .iPhoneSe), traits: .init(userInterfaceStyle: .light)), + named: "device") + } #endif #if os(tvOS) - @available(tvOS 13.0, *) - func testSwiftUIView_tvOS() { - struct MyView: SwiftUI.View { - var body: some SwiftUI.View { - HStack { - Image(systemName: "checkmark.circle.fill") + @available(tvOS 13.0, *) + func testSwiftUIView_tvOS() { + struct MyView: SwiftUI.View { + var body: some SwiftUI.View { + HStack { + Image(systemName: "checkmark.circle.fill") Text("Checked").fixedSize() + } + .padding(5) + .background(RoundedRectangle(cornerRadius: 5.0).fill(Color.blue)) + .padding(10) } - .padding(5) - .background(RoundedRectangle(cornerRadius: 5.0).fill(Color.blue)) - .padding(10) } - } - let view = MyView().background(Color.yellow) + let view = MyView().background(Color.yellow) - assertSnapshot(of: view, as: .image()) - assertSnapshot(of: view, as: .image(layout: .sizeThatFits), named: "size-that-fits") - assertSnapshot(of: view, as: .image(layout: .fixed(width: 300.0, height: 100.0)), named: "fixed") - assertSnapshot(of: view, as: .image(layout: .device(config: .tv)), named: "device") - } + assertSnapshot(of: view, as: .image()) + assertSnapshot(of: view, as: .image(layout: .sizeThatFits), named: "size-that-fits") + assertSnapshot( + of: view, as: .image(layout: .fixed(width: 300.0, height: 100.0)), named: "fixed") + assertSnapshot(of: view, as: .image(layout: .device(config: .tv)), named: "device") + } #endif @available(*, deprecated) @@ -1216,46 +1328,46 @@ final class SnapshotTestingTests: XCTestCase { } #if os(iOS) -private let allContentSizes = - [ - "extra-small": UIContentSizeCategory.extraSmall, - "small": .small, - "medium": .medium, - "large": .large, - "extra-large": .extraLarge, - "extra-extra-large": .extraExtraLarge, - "extra-extra-extra-large": .extraExtraExtraLarge, - "accessibility-medium": .accessibilityMedium, - "accessibility-large": .accessibilityLarge, - "accessibility-extra-large": .accessibilityExtraLarge, - "accessibility-extra-extra-large": .accessibilityExtraExtraLarge, - "accessibility-extra-extra-extra-large": .accessibilityExtraExtraExtraLarge, + private let allContentSizes = + [ + "extra-small": UIContentSizeCategory.extraSmall, + "small": .small, + "medium": .medium, + "large": .large, + "extra-large": .extraLarge, + "extra-extra-large": .extraExtraLarge, + "extra-extra-extra-large": .extraExtraExtraLarge, + "accessibility-medium": .accessibilityMedium, + "accessibility-large": .accessibilityLarge, + "accessibility-extra-large": .accessibilityExtraLarge, + "accessibility-extra-extra-large": .accessibilityExtraExtraLarge, + "accessibility-extra-extra-extra-large": .accessibilityExtraExtraExtraLarge, ] #endif #if os(Linux) || os(Windows) -extension SnapshotTestingTests { - static var allTests : [(String, (SnapshotTestingTests) -> () throws -> Void)] { - return [ - ("testAny", testAny), - ("testAnySnapshotStringConvertible", testAnySnapshotStringConvertible), - ("testAutolayout", testAutolayout), - ("testDeterministicDictionaryAndSetSnapshots", testDeterministicDictionaryAndSetSnapshots), - ("testEncodable", testEncodable), - ("testMixedViews", testMixedViews), - ("testMultipleSnapshots", testMultipleSnapshots), - ("testNamedAssertion", testNamedAssertion), - ("testPrecision", testPrecision), - ("testSCNView", testSCNView), - ("testSKView", testSKView), - ("testTableViewController", testTableViewController), - ("testTraits", testTraits), - ("testTraitsEmbeddedInTabNavigation", testTraitsEmbeddedInTabNavigation), - ("testTraitsWithView", testTraitsWithView), - ("testUIView", testUIView), - ("testURLRequest", testURLRequest), - ("testWebView", testWebView), - ] + extension SnapshotTestingTests { + static var allTests: [(String, (SnapshotTestingTests) -> () throws -> Void)] { + return [ + ("testAny", testAny), + ("testAnySnapshotStringConvertible", testAnySnapshotStringConvertible), + ("testAutolayout", testAutolayout), + ("testDeterministicDictionaryAndSetSnapshots", testDeterministicDictionaryAndSetSnapshots), + ("testEncodable", testEncodable), + ("testMixedViews", testMixedViews), + ("testMultipleSnapshots", testMultipleSnapshots), + ("testNamedAssertion", testNamedAssertion), + ("testPrecision", testPrecision), + ("testSCNView", testSCNView), + ("testSKView", testSKView), + ("testTableViewController", testTableViewController), + ("testTraits", testTraits), + ("testTraitsEmbeddedInTabNavigation", testTraitsEmbeddedInTabNavigation), + ("testTraitsWithView", testTraitsWithView), + ("testUIView", testUIView), + ("testURLRequest", testURLRequest), + ("testWebView", testWebView), + ] + } } -} #endif diff --git a/Tests/SnapshotTestingTests/TestHelpers.swift b/Tests/SnapshotTestingTests/TestHelpers.swift index aaeb86d7e..d71c855f1 100644 --- a/Tests/SnapshotTestingTests/TestHelpers.swift +++ b/Tests/SnapshotTestingTests/TestHelpers.swift @@ -1,107 +1,110 @@ -@testable import SnapshotTesting import XCTest +@testable import SnapshotTesting + #if os(iOS) -let platform = "ios" + let platform = "ios" #elseif os(tvOS) -let platform = "tvos" + let platform = "tvos" #elseif os(macOS) -let platform = "macos" -extension NSTextField { - var text: String { - get { return self.stringValue } - set { self.stringValue = newValue } + let platform = "macos" + extension NSTextField { + var text: String { + get { return self.stringValue } + set { self.stringValue = newValue } + } } -} #endif #if os(macOS) || os(iOS) || os(tvOS) -extension CGPath { - /// Creates an approximation of a heart at a 45º angle with a circle above, using all available element types: - static var heart: CGPath { - let scale: CGFloat = 30.0 - let path = CGMutablePath() + extension CGPath { + /// Creates an approximation of a heart at a 45º angle with a circle above, using all available element types: + static var heart: CGPath { + let scale: CGFloat = 30.0 + let path = CGMutablePath() - path.move(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) - path.addLine(to: CGPoint(x: 0.0 * scale, y: 2.0 * scale)) - path.addQuadCurve( + path.move(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) + path.addLine(to: CGPoint(x: 0.0 * scale, y: 2.0 * scale)) + path.addQuadCurve( to: CGPoint(x: 1.0 * scale, y: 3.0 * scale), control: CGPoint(x: 0.125 * scale, y: 2.875 * scale) - ) - path.addQuadCurve( + ) + path.addQuadCurve( to: CGPoint(x: 2.0 * scale, y: 2.0 * scale), control: CGPoint(x: 1.875 * scale, y: 2.875 * scale) - ) - path.addCurve( + ) + path.addCurve( to: CGPoint(x: 3.0 * scale, y: 1.0 * scale), control1: CGPoint(x: 2.5 * scale, y: 2.0 * scale), control2: CGPoint(x: 3.0 * scale, y: 1.5 * scale) - ) - path.addCurve( + ) + path.addCurve( to: CGPoint(x: 2.0 * scale, y: 0.0 * scale), control1: CGPoint(x: 3.0 * scale, y: 0.5 * scale), control2: CGPoint(x: 2.5 * scale, y: 0.0 * scale) - ) - path.addLine(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) - path.closeSubpath() + ) + path.addLine(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) + path.closeSubpath() - path.addEllipse(in: CGRect( - origin: CGPoint(x: 2.0 * scale, y: 2.0 * scale), - size: CGSize(width: scale, height: scale) - )) + path.addEllipse( + in: CGRect( + origin: CGPoint(x: 2.0 * scale, y: 2.0 * scale), + size: CGSize(width: scale, height: scale) + )) - return path + return path + } } -} #endif #if os(iOS) || os(tvOS) -extension UIBezierPath { - /// Creates an approximation of a heart at a 45º angle with a circle above, using all available element types: - static var heart: UIBezierPath { - UIBezierPath(cgPath: .heart) + extension UIBezierPath { + /// Creates an approximation of a heart at a 45º angle with a circle above, using all available element types: + static var heart: UIBezierPath { + UIBezierPath(cgPath: .heart) + } } -} #endif #if os(macOS) -extension NSBezierPath { - /// Creates an approximation of a heart at a 45º angle with a circle above, using all available element types: - static var heart: NSBezierPath { - let scale: CGFloat = 30.0 - let path = NSBezierPath() + extension NSBezierPath { + /// Creates an approximation of a heart at a 45º angle with a circle above, using all available element types: + static var heart: NSBezierPath { + let scale: CGFloat = 30.0 + let path = NSBezierPath() - path.move(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) - path.line(to: CGPoint(x: 0.0 * scale, y: 2.0 * scale)) - path.curve( + path.move(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) + path.line(to: CGPoint(x: 0.0 * scale, y: 2.0 * scale)) + path.curve( to: CGPoint(x: 1.0 * scale, y: 3.0 * scale), controlPoint1: CGPoint(x: 0.0 * scale, y: 2.5 * scale), controlPoint2: CGPoint(x: 0.5 * scale, y: 3.0 * scale) - ) - path.curve( + ) + path.curve( to: CGPoint(x: 2.0 * scale, y: 2.0 * scale), controlPoint1: CGPoint(x: 1.5 * scale, y: 3.0 * scale), controlPoint2: CGPoint(x: 2.0 * scale, y: 2.5 * scale) - ) - path.curve( + ) + path.curve( to: CGPoint(x: 3.0 * scale, y: 1.0 * scale), controlPoint1: CGPoint(x: 2.5 * scale, y: 2.0 * scale), controlPoint2: CGPoint(x: 3.0 * scale, y: 1.5 * scale) - ) - path.curve( + ) + path.curve( to: CGPoint(x: 2.0 * scale, y: 0.0 * scale), controlPoint1: CGPoint(x: 3.0 * scale, y: 0.5 * scale), controlPoint2: CGPoint(x: 2.5 * scale, y: 0.0 * scale) - ) - path.line(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) - path.close() + ) + path.line(to: CGPoint(x: 0.0 * scale, y: 0.0 * scale)) + path.close() - path.appendOval(in: CGRect( - origin: CGPoint(x: 2.0 * scale, y: 2.0 * scale), - size: CGSize(width: scale, height: scale) - )) + path.appendOval( + in: CGRect( + origin: CGPoint(x: 2.0 * scale, y: 2.0 * scale), + size: CGSize(width: scale, height: scale) + )) - return path + return path + } } -} #endif diff --git a/Tests/SnapshotTestingTests/WaitTests.swift b/Tests/SnapshotTestingTests/WaitTests.swift index 124a6b410..53bdd7be7 100644 --- a/Tests/SnapshotTestingTests/WaitTests.swift +++ b/Tests/SnapshotTestingTests/WaitTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import SnapshotTesting class WaitTests: XCTestCase { diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotEscapedNewlineLastLine.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotEscapedNewlineLastLine.1.swift deleted file mode 100644 index ca6142c79..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotEscapedNewlineLastLine.1.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotEscapedNewlineLastLine() { - let diffable = #######""" - abc \ - cde \ - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - abc \ - cde \ - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotMultiLine.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotMultiLine.1.swift deleted file mode 100644 index 1d54a0b59..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotMultiLine.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotMultiLine() { - let diffable = #######""" - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotSingleLine.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotSingleLine.1.swift deleted file mode 100644 index ba1545026..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotSingleLine.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotSingleLine() { - let diffable = #######""" - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiter1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiter1.1.swift deleted file mode 100644 index 0278fb4ac..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiter1.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithExtendedDelimiter1() { - let diffable = #######""" - \" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiter2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiter2.1.swift deleted file mode 100644 index 53df254e2..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiter2.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithExtendedDelimiter2() { - let diffable = #######""" - \"""# - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \"""# - """##) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiterSingleLine1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiterSingleLine1.1.swift deleted file mode 100644 index dc302c39f..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiterSingleLine1.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithExtendedDelimiterSingleLine1() { - let diffable = #######""" - \" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiterSingleLine2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiterSingleLine2.1.swift deleted file mode 100644 index ed9182fb4..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithExtendedDelimiterSingleLine2.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithExtendedDelimiterSingleLine2() { - let diffable = #######""" - \"""# - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \"""# - """##) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithLongerExtendedDelimiter1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithLongerExtendedDelimiter1.1.swift deleted file mode 100644 index 571755dc1..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithLongerExtendedDelimiter1.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithLongerExtendedDelimiter1() { - let diffable = #######""" - \" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithLongerExtendedDelimiter2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithLongerExtendedDelimiter2.1.swift deleted file mode 100644 index f9e5fd2dd..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithLongerExtendedDelimiter2.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithLongerExtendedDelimiter2() { - let diffable = #######""" - \"""# - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \"""# - """##) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithShorterExtendedDelimiter1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithShorterExtendedDelimiter1.1.swift deleted file mode 100644 index f06efb4bf..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithShorterExtendedDelimiter1.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithShorterExtendedDelimiter1() { - let diffable = #######""" - \" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithShorterExtendedDelimiter2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithShorterExtendedDelimiter2.1.swift deleted file mode 100644 index 0eac85f10..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testCreateSnapshotWithShorterExtendedDelimiter2.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testCreateSnapshotWithShorterExtendedDelimiter2() { - let diffable = #######""" - \"""# - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \"""# - """##) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsSwapingLines1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsSwapingLines1.1.swift deleted file mode 100644 index f515683c9..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsSwapingLines1.1.swift +++ /dev/null @@ -1,22 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSeveralSnapshotsSwapingLines1() { - let diffable = #######""" - NEW_SNAPSHOT - with two lines - """####### - - let diffable2 = #######""" - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - with two lines - """) - _assertInlineSnapshot(matching: diffable2, as: .lines, with: """ - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsSwapingLines2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsSwapingLines2.1.swift deleted file mode 100644 index 60e0bdb0b..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsSwapingLines2.1.swift +++ /dev/null @@ -1,22 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSeveralSnapshotsSwapingLines2() { - let diffable = #######""" - NEW_SNAPSHOT - """####### - - let diffable2 = #######""" - NEW_SNAPSHOT - with two lines - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - """) - _assertInlineSnapshot(matching: diffable2, as: .lines, with: """ - NEW_SNAPSHOT - with two lines - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsWithLessLines.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsWithLessLines.1.swift deleted file mode 100644 index 385a83ea0..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsWithLessLines.1.swift +++ /dev/null @@ -1,20 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSeveralSnapshotsWithLessLines() { - let diffable = #######""" - NEW_SNAPSHOT - """####### - - let diffable2 = #######""" - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - """) - _assertInlineSnapshot(matching: diffable2, as: .lines, with: """ - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsWithMoreLines.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsWithMoreLines.1.swift deleted file mode 100644 index a414b4c30..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSeveralSnapshotsWithMoreLines.1.swift +++ /dev/null @@ -1,22 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSeveralSnapshotsWithMoreLines() { - let diffable = #######""" - NEW_SNAPSHOT - with two lines - """####### - - let diffable2 = #######""" - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - with two lines - """) - _assertInlineSnapshot(matching: diffable2, as: .lines, with: """ - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshot.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshot.1.swift deleted file mode 100644 index dff5625f4..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshot.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshot() { - let diffable = #######""" - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotCombined1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotCombined1.1.swift deleted file mode 100644 index c46076071..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotCombined1.1.swift +++ /dev/null @@ -1,19 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotCombined1() { - let diffable = #######""" - ▿ User - - bio: "Blobbed around the world." - - id: 1 - - name: "Bl#\"\"#obby" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - ▿ User - - bio: "Blobbed around the world." - - id: 1 - - name: "Bl#\"\"#obby" - """##) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithExtendedDelimiter1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithExtendedDelimiter1.1.swift deleted file mode 100644 index 9ab6a37b2..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithExtendedDelimiter1.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithExtendedDelimiter1() { - let diffable = #######""" - \" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithExtendedDelimiter2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithExtendedDelimiter2.1.swift deleted file mode 100644 index 238a24890..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithExtendedDelimiter2.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithExtendedDelimiter2() { - let diffable = #######""" - \"""# - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \"""# - """##) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLessLines.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLessLines.1.swift deleted file mode 100644 index 1b4657ab6..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLessLines.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithLessLines() { - let diffable = #######""" - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLongerExtendedDelimiter1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLongerExtendedDelimiter1.1.swift deleted file mode 100644 index 11fbfebc9..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLongerExtendedDelimiter1.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithLongerExtendedDelimiter1() { - let diffable = #######""" - \" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLongerExtendedDelimiter2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLongerExtendedDelimiter2.1.swift deleted file mode 100644 index 9285b0e76..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithLongerExtendedDelimiter2.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithLongerExtendedDelimiter2() { - let diffable = #######""" - \"""# - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \"""# - """##) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithMoreLines.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithMoreLines.1.swift deleted file mode 100644 index 7cfb3b6e7..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithMoreLines.1.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithMoreLines() { - let diffable = #######""" - NEW_SNAPSHOT - NEW_SNAPSHOT - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: """ - NEW_SNAPSHOT - NEW_SNAPSHOT - """) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithShorterExtendedDelimiter1.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithShorterExtendedDelimiter1.1.swift deleted file mode 100644 index 58c1c77fa..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithShorterExtendedDelimiter1.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithShorterExtendedDelimiter1() { - let diffable = #######""" - \" - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: #""" - \" - """#) - } -} \ No newline at end of file diff --git a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithShorterExtendedDelimiter2.1.swift b/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithShorterExtendedDelimiter2.1.swift deleted file mode 100644 index f00ee4e1d..000000000 --- a/Tests/SnapshotTestingTests/__Snapshots__/InlineSnapshotTests/testUpdateSnapshotWithShorterExtendedDelimiter2.1.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import SnapshotTesting -extension InlineSnapshotsValidityTests { - func testUpdateSnapshotWithShorterExtendedDelimiter2() { - let diffable = #######""" - \"""# - """####### - - _assertInlineSnapshot(matching: diffable, as: .lines, with: ##""" - \"""# - """##) - } -} \ No newline at end of file