diff --git a/Sources/TokamakCore/Stubs/CGStubs.swift b/Sources/TokamakCore/Stubs/CGStubs.swift index 67e03a5fd..95329f309 100644 --- a/Sources/TokamakCore/Stubs/CGStubs.swift +++ b/Sources/TokamakCore/Stubs/CGStubs.swift @@ -22,6 +22,7 @@ extension CGPoint { func rotate(_ angle: Angle, around origin: Self) -> Self { let cosAngle = CGFloat(cos(angle.radians)) let sinAngle = CGFloat(sin(angle.radians)) + return .init( x: cosAngle * (x - origin.x) - sinAngle * (y - origin.y) + origin.x, y: sinAngle * (x - origin.x) + cosAngle * (y - origin.y) + origin.y @@ -36,16 +37,6 @@ extension CGPoint { } } -public extension CGAffineTransform { - /// Transform the point into the transform's coordinate system. - func transform(point: CGPoint) -> CGPoint { - CGPoint( - x: (a * point.x) + (c * point.y) + tx, - y: (b * point.x) + (d * point.y) + ty - ) - } -} - #if !canImport(CoreGraphics) public enum CGLineCap { /// A line with a squared-off end. Extends to the endpoint of the Path. @@ -63,149 +54,248 @@ public enum CGLineJoin { /// A join with a squared-off end. Extends past the endpoint of the Path. case bevel } +#endif + +#if !canImport(CoreGraphics) +// For cross-platform testing. +@_spi(Tests) +public struct _CGAffineTransform { + // Internal for testing purposes. + internal let _transform: AffineTransform +} +#else +public struct _CGAffineTransform { + // Internal for testing purposes. + internal private(set) var _transform: AffineTransform +} /// An affine transformation matrix for use in drawing 2D graphics. /// /// a b 0 /// c d 0 /// tx ty 1 -public struct CGAffineTransform: Equatable { - public var a: CGFloat - public var b: CGFloat - public var c: CGFloat - public var d: CGFloat - public var tx: CGFloat - public var ty: CGFloat - - /// The identity matrix - public static let identity: Self = .init( - a: 1, - b: 0, // 0 - c: 0, - d: 1, // 0 - tx: 0, - ty: 0 - ) // 1 - - public init( +public typealias CGAffineTransform = _CGAffineTransform +#endif + +extension _CGAffineTransform: Equatable {} + +extension _CGAffineTransform: Codable { + public init(from decoder: Decoder) throws { + try self.init( + _transform: AffineTransform(from: decoder) + ) + } + + public func encode(to encoder: Encoder) throws { + try _transform.encode(to: encoder) + } +} + +public extension _CGAffineTransform { + /// The value at position [1,1] in the matrix. + var a: CGFloat { + get { _transform.m11 } + set { _transform.m11 = newValue } + } + + /// The value at position [1,2] in the matrix. + var b: CGFloat { + get { _transform.m12 } + set { _transform.m12 = newValue } + } + + /// The value at position [2,1] in the matrix. + var c: CGFloat { + get { _transform.m21 } + set { _transform.m21 = newValue } + } + + /// The value at position [2,2] in the matrix. + var d: CGFloat { + get { _transform.m22 } + set { _transform.m22 = newValue } + } + + /// The value at position [3,1] in the matrix. + var tx: CGFloat { + get { _transform.tX } + set { _transform.tX = newValue } + } + + /// The value at position [3,2] in the matrix. + var ty: CGFloat { + get { _transform.tY } + set { _transform.tY = newValue } + } + + /// Creates an affine transform with the given matrix values. + /// + /// - Parameters: + /// - a: The value at position [1,1] in the matrix. + /// - b: The value at position [1,2] in the matrix. + /// - c: The value at position [2,1] in the matrix. + /// - d: The value at position [2,2] in the matrix. + /// - tx: The value at position [3,1] in the matrix. + /// - ty: The value at position [3,2] in the matrix. + init( a: CGFloat, b: CGFloat, c: CGFloat, d: CGFloat, tx: CGFloat, ty: CGFloat ) { - self.a = a - self.b = b - self.c = c - self.d = d - self.tx = tx - self.ty = ty + self.init(_transform: AffineTransform( + m11: a, m12: b, + m21: c, m22: d, + tX: tx, tY: ty + )) } +} - /// Returns an affine transformation matrix constructed from a rotation value you provide. +public extension _CGAffineTransform { + /// The identity transformation matrix. + static let identity = Self(_transform: .identity) + + /// Creates the identity transformation matrix. + init() { + self.init(_transform: AffineTransform()) + } + + var isIdentity: Bool { + self == .identity + } +} + +public extension _CGAffineTransform { + /// Creates an affine transformation matrix constructed from a rotation value you + /// provide. + /// /// - Parameters: - /// - angle: The angle, in radians, by which this matrix rotates the coordinate system axes. - /// A positive value specifies clockwise rotation and anegative value specifies - /// counterclockwise rotation. - public init(rotationAngle angle: CGFloat) { - self.init(a: cos(angle), b: sin(angle), c: -sin(angle), d: cos(angle), tx: 0, ty: 0) + /// - angle: The angle, in radians, by which this matrix rotates the coordinate + /// system axes. A positive value specifies clockwise rotation and a negative value + /// specifies counterclockwise rotation. + init(rotationAngle angle: CGFloat) { + self.init(_transform: AffineTransform(rotationByRadians: angle)) } - /// Returns an affine transformation matrix constructed from scaling values you provide. + /// Creates an affine transformation matrix constructed from scaling values you provide. + /// /// - Parameters: /// - sx: The factor by which to scale the x-axis of the coordinate system. /// - sy: The factor by which to scale the y-axis of the coordinate system. - public init(scaleX sx: CGFloat, y sy: CGFloat) { - self.init( - a: sx, - b: 0, - c: 0, - d: sy, - tx: 0, - ty: 0 - ) + init(scaleX x: CGFloat, y: CGFloat) { + self.init(_transform: AffineTransform(scaleByX: x, byY: y)) } - /// Returns an affine transformation matrix constructed from translation values you provide. + /// Creates an affine transformation matrix constructed from translation values you + /// provide. + /// /// - Parameters: /// - tx: The value by which to move the x-axis of the coordinate system. /// - ty: The value by which to move the y-axis of the coordinate system. - public init(translationX tx: CGFloat, y ty: CGFloat) { - self.init( - a: 1, - b: 0, - c: 0, - d: 1, - tx: tx, - ty: ty - ) + init(translationX x: CGFloat, y: CGFloat) { + self.init(_transform: AffineTransform(translationByX: x, byY: y)) } +} + +public extension _CGAffineTransform { + private func withBackingTransform( + _ modify: (inout AffineTransform) -> () + ) -> Self { + var newTransform = _transform + modify(&newTransform) + return _CGAffineTransform(_transform: newTransform) + } +} + +public extension _CGAffineTransform { /// Returns an affine transformation matrix constructed by combining two existing affine /// transforms. + /// + /// Note that concatenation is not commutative, meaning that order is important. For + /// instance, `t1.concatenating(t2)` != `t2.concatenating(t1)` — where + /// `t1` and `t2` are`CGAffineTransform` instances. + /// + /// - Postcondition: The returned transformation is invertible if both `self` and + /// the given transformation (`t2`) are invertible. + /// /// - Parameters: /// - t2: The affine transform to concatenate to this affine transform. /// - Returns: A new affine transformation matrix. That is, `t’ = t1*t2`. - public func concatenating(_ t2: Self) -> Self { - let t1m = [[a, b, 0], - [c, d, 0], - [tx, ty, 1]] - let t2m = [[t2.a, t2.b, 0], - [t2.c, t2.d, 0], - [t2.tx, t2.ty, 1]] - var res: [[CGFloat]] = [[0, 0, 0], - [0, 0, 0], - [0, 0, 0]] - for i in 0..<3 { - for j in 0..<3 { - res[i][j] = 0 - for k in 0..<3 { - res[i][j] += t1m[i][k] * t2m[k][j] - } - } - } - return .init( - a: res[0][0], - b: res[0][1], - c: res[1][0], - d: res[1][1], - tx: res[2][0], - ty: res[2][1] - ) + func concatenating(_ t2: Self) -> Self { + withBackingTransform { $0.append(t2._transform) } } +} + +public extension _CGAffineTransform { + /// Returns an affine transformation matrix constructed by inverting an existing affine + /// transform. + /// + /// - Postcondition: Invertibility is preserved, meaning that if `self` is + /// invertible, so the returned transformation will also be invertible. + /// + /// - Returns: A new affine transformation matrix. If `self` is not invertible, it's + /// returned unchanged. + func inverted() -> Self { + withBackingTransform { _transform in + guard let inverted = _transform.inverted() else { + fatalError("This affine transform is non invertible.") + } - /// Returns an affine transformation matrix constructed by inverting an existing affine transform. - public func inverted() -> Self { - .init(a: -a, b: -b, c: -c, d: -d, tx: -tx, ty: -ty) + _transform = inverted + } } +} - /// Returns an affine transformation matrix constructed by rotating an existing affine transform. +public extension _CGAffineTransform { + /// Returns an affine transformation matrix constructed by rotating an existing affine + /// transform. + /// /// - Parameters: /// - angle: The angle, in radians, by which to rotate the affine transform. /// A positive value specifies clockwise rotation and a negative value specifies /// counterclockwise rotation. - public func rotated(by angle: CGFloat) -> Self { - Self(a: cos(angle), b: sin(angle), c: -sin(angle), d: cos(angle), tx: 0, ty: 0) + func rotated(by angle: CGFloat) -> Self { + withBackingTransform { $0.rotate(byRadians: angle) } + } + + /// Returns an affine transformation matrix constructed by scaling an existing affine + /// transform. + /// + /// - Postcondition: Invertibility is preserved if both `sx` and `sy` aren't `0`. + /// + /// - Parameters: + /// - sx: The value by which to scale x values of the affine transform. + /// - sy: The value by which to scale y values of the affine transform. + func scaledBy(x: CGFloat, y: CGFloat) -> Self { + withBackingTransform { $0.scale(x: x, y: y) } } /// Returns an affine transformation matrix constructed by translating an existing /// affine transform. + /// /// - Parameters: /// - tx: The value by which to move x values with the affine transform. /// - ty: The value by which to move y values with the affine transform. - public func translatedBy(x tx: CGFloat, y ty: CGFloat) -> Self { - .init(a: a, b: b, c: c, d: d, tx: self.tx + tx, ty: self.ty + ty) + func translatedBy(x: CGFloat, y: CGFloat) -> Self { + withBackingTransform { $0.translate(x: x, y: y) } } +} - /// Returns an affine transformation matrix constructed by scaling an existing affine transform. - /// - Parameters: - /// - sx: The value by which to scale x values of the affine transform. - /// - sy: The value by which to scale y values of the affine transform. - public func scaledBy(x sx: CGFloat, y sy: CGFloat) -> Self { - .init(a: a + sx, b: b, c: c, d: d + sy, tx: tx, ty: ty) +internal extension _CGAffineTransform { + func _transform(point: CGPoint) -> CGPoint { + _transform.transform(point) } +} - public var isIdentity: Bool { - self == Self.identity +public extension CGAffineTransform { + /// Transform the point into the transform's coordinate system. + func transform(point: CGPoint) -> CGPoint { + let transform = _CGAffineTransform( + a: a, b: b, + c: c, d: d, + tx: tx, ty: ty + ) + + return transform._transform(point: point) } } - -#endif diff --git a/Tests/TokamakTests/AffineTransform.swift b/Tests/TokamakTests/AffineTransform.swift new file mode 100644 index 000000000..d2c1572d8 --- /dev/null +++ b/Tests/TokamakTests/AffineTransform.swift @@ -0,0 +1,523 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@_spi(Tests) +@testable import TokamakCore + +import XCTest + +typealias CGAffineTransform = TokamakCore._CGAffineTransform + +// MARK: - Tests + +final class CGAffineTransformTest: XCTestCase { + private let accuracyThreshold = 0.001 + + static var allTests: [(String, (CGAffineTransformTest) -> () throws -> ())] { + [ + ("testConstruction", testConstruction), + ("testVectorTransformations", testVectorTransformations), + ("testIdentityConstruction", testIdentityConstruction), + ("testIdentity", testIdentity), + ("testTranslationConstruction", testTranslationConstruction), + ("testTranslation", testTranslation), + ("testScalingConstruction", testScalingConstruction), + ("testScaling", testScaling), + ("testRotationConstruction", testRotationConstruction), + ("testRotation", testRotation), + ("testTranslationScaling", testTranslationScaling), + ("testTranslationRotation", testTranslationRotation), + ("testScalingRotation", testScalingRotation), + ("testInversion", testInversion), + ("testConcatenation", testConcatenation), + ("testCoding", testCoding), + ] + } +} + +// MARK: - Helper + +extension CGAffineTransformTest { + func check( + point: CGPoint, + withTransform transform: CGAffineTransform, + mapsTo expectedPoint: CGPoint, + _ message: String = "", + file: StaticString = #file, line: UInt = #line + ) { + let newPoint = transform.transform(point: point) + + XCTAssertEqual( + newPoint.x, expectedPoint.x, + accuracy: accuracyThreshold, + "Invalid x: \(message)", + file: file, line: line + ) + + XCTAssertEqual( + newPoint.y, expectedPoint.y, + accuracy: accuracyThreshold, + "Invalid y: \(message)", + file: file, line: line + ) + } +} + +// MARK: - Construction + +extension CGAffineTransformTest { + func testConstruction() { + let transform = CGAffineTransform( + a: 1, b: 2, + c: 3, d: 4, + tx: 5, ty: 6 + ) + + XCTAssertEqual(transform.a, 1) + XCTAssertEqual(transform.b, 2) + XCTAssertEqual(transform.c, 3) + XCTAssertEqual(transform.d, 4) + XCTAssertEqual(transform.tx, 5) + XCTAssertEqual(transform.ty, 6) + + let mutatedTransform: CGAffineTransform = { + var copy = transform + + copy.a = -1 + copy.b = -2 + copy.c = -3 + copy.d = -4 + copy.tx = -5 + copy.ty = -6 + + return copy + }() + + XCTAssertEqual(mutatedTransform.a, -1) + XCTAssertEqual(mutatedTransform.b, -2) + XCTAssertEqual(mutatedTransform.c, -3) + XCTAssertEqual(mutatedTransform.d, -4) + XCTAssertEqual(mutatedTransform.tx, -5) + XCTAssertEqual(mutatedTransform.ty, -6) + } +} + +// MARK: - Vector Transformations + +extension CGAffineTransformTest { + func testVectorTransformations() { + // To transform a given point with coordinates x and y, + // we do: + // + // [ m11 m12 0 ] + // [ w' h' 1 ] = [ x y 1 ] * [ m21 m22 0 ] + // [ tx ty 1 ] + // + // [ w' h' 1 ] = [ x*m11+y*m21+1*tX x*m12+y*m22+1*tY ] + + check( + point: CGPoint(x: 10, y: 20), + withTransform: CGAffineTransform( + a: 1, b: 2, + c: 3, d: 4, + tx: 5, ty: 6 + ), + + // [ px*m11+py*m21+tX px*m12+py*m22+tY ] + // [ 10*1+20*3+5 10*2+20*4+6 ] + // [ 75 106 ] + mapsTo: CGPoint(x: 75, y: 106) + ) + + check( + point: CGPoint(x: 5, y: 25), + withTransform: CGAffineTransform( + a: 5, b: 4, + c: 3, d: 2, + tx: 1, ty: 0 + ), + + // [ px*m11+py*m21+tX px*m12+py*m22+tY ] + // [ 5*5+25*3+1 5*4+25*2+0 ] + // [ 101 70 ] + mapsTo: CGPoint(x: 101, y: 70) + ) + } +} + +// MARK: - Identity + +extension CGAffineTransformTest { + func testIdentityConstruction() { + // Check that the transform matrix is the identity: + // [ 1 0 0 ] + // [ 0 1 0 ] + // [ 0 0 1 ] + let identity = CGAffineTransform( + a: 1, b: 0, + c: 0, d: 1, + tx: 0, ty: 0 + ) + + XCTAssertEqual(CGAffineTransform(), identity) + XCTAssertEqual(CGAffineTransform.identity, identity) + } + + func testIdentity() { + check( + point: CGPoint(x: 25, y: 10), + withTransform: .identity, + mapsTo: CGPoint(x: 25, y: 10) + ) + } +} + +// MARK: - Translation + +extension CGAffineTransformTest { + func testTranslationConstruction() { + let translatedIdentity = CGAffineTransform.identity + .translatedBy(x: 15, y: 20) + + let translation = CGAffineTransform( + translationX: 15, y: 20 + ) + + XCTAssertEqual(translatedIdentity, translation) + } + + func testTranslation() { + check( + point: CGPoint(x: 10, y: 10), + withTransform: CGAffineTransform( + translationX: 0, y: 0 + ), + mapsTo: CGPoint(x: 10, y: 10) + ) + + check( + point: CGPoint(x: 10, y: 10), + withTransform: CGAffineTransform( + translationX: 0, y: 5 + ), + mapsTo: CGPoint(x: 10, y: 15) + ) + + check( + point: CGPoint(x: 10, y: 10), + withTransform: CGAffineTransform( + translationX: 5, y: 5 + ), + mapsTo: CGPoint(x: 15, y: 15) + ) + + check( + point: CGPoint(x: -2, y: -3), + // Translate by 5 + withTransform: CGAffineTransform.identity + .translatedBy(x: 2, y: 3) + .translatedBy(x: 3, y: 2), + mapsTo: CGPoint(x: 3, y: 2) + ) + } +} + +// MARK: - Scaling + +extension CGAffineTransformTest { + func testScalingConstruction() { + let scaledIdentity = CGAffineTransform.identity + .scaledBy(x: 15, y: 20) + + let scaling = CGAffineTransform( + scaleX: 15, y: 20 + ) + + XCTAssertEqual(scaledIdentity, scaling) + } + + func testScaling() { + check( + point: CGPoint(x: 10, y: 10), + withTransform: CGAffineTransform( + scaleX: 1, y: 0 + ), + mapsTo: CGPoint(x: 10, y: 0) + ) + + check( + point: CGPoint(x: 10, y: 10), + withTransform: CGAffineTransform( + scaleX: 0.5, y: 1 + ), + mapsTo: CGPoint(x: 5, y: 10) + ) + + check( + point: CGPoint(x: 10, y: 10), + withTransform: CGAffineTransform( + scaleX: 0, y: 2 + ), + mapsTo: CGPoint(x: 0, y: 20) + ) + + check( + point: CGPoint(x: 10, y: 10), + // Scale by (2, 0) + withTransform: CGAffineTransform.identity + .scaledBy(x: 4, y: 0) + .scaledBy(x: 0.5, y: 1), + mapsTo: CGPoint(x: 20, y: 0) + ) + } +} + +// MARK: - Rotation + +extension CGAffineTransformTest { + func testRotationConstruction() { + let baseRotation = CGAffineTransform(rotationAngle: .pi) + + let point = CGPoint(x: 10, y: 15) + let expectedPoint = baseRotation.transform(point: point) + + check( + point: point, + withTransform: .identity.rotated(by: .pi), + mapsTo: expectedPoint, + "Rotation operation on identity doesn't work as identity init." + ) + } + + func testRotation() { + check( + point: CGPoint(x: 10, y: 15), + withTransform: CGAffineTransform(rotationAngle: 0), + mapsTo: CGPoint(x: 10, y: 15) + ) + + check( + point: CGPoint(x: 10, y: 15), + // Rotate by 3*360º + withTransform: CGAffineTransform(rotationAngle: .pi * 6), + mapsTo: CGPoint(x: 10, y: 15) + ) + + // Counter-clockwise rotation + check( + point: CGPoint(x: 15, y: 10), + // Rotate by 90º + withTransform: CGAffineTransform(rotationAngle: .pi / 2), + mapsTo: CGPoint(x: -10, y: 15) + ) + + // Clockwise rotation + check( + point: CGPoint(x: 15, y: 10), + // Rotate by -90º + withTransform: CGAffineTransform(rotationAngle: .pi / -2), + mapsTo: CGPoint(x: 10, y: -15) + ) + + // Reflect about origin + check( + point: CGPoint(x: 10, y: 15), + // Rotate by 180º + withTransform: CGAffineTransform(rotationAngle: .pi), + mapsTo: CGPoint(x: -10, y: -15) + ) + + // Composed reflection about origin + check( + point: CGPoint(x: 10, y: 15), + // Rotate by 180º + withTransform: CGAffineTransform.identity + .rotated(by: .pi / 2) + .rotated(by: .pi / 2), + mapsTo: CGPoint(x: -10, y: -15) + ) + } +} + +// MARK: - Permutations + +extension CGAffineTransformTest { + func testTranslationScaling() { + check( + point: CGPoint(x: 1, y: 3), + // Translate by (2, 0) then scale by (5, -5) + withTransform: CGAffineTransform.identity + .translatedBy(x: 2, y: 0) + .scaledBy(x: 5, y: -5), + mapsTo: CGPoint(x: 15, y: -15) + ) + + check( + point: CGPoint(x: 3, y: 1), + // Scale by (-5, 5) then scale by (0, 10) + withTransform: CGAffineTransform.identity + .scaledBy(x: -5, y: 5) + .translatedBy(x: 0, y: 10), + mapsTo: CGPoint(x: -15, y: 15) + ) + } + + func testTranslationRotation() { + check( + point: CGPoint(x: 10, y: 10), + // Translate by (20, -5) then rotate by 90º + withTransform: CGAffineTransform.identity + .translatedBy(x: 20, y: -5) + .rotated(by: .pi / 2), + mapsTo: CGPoint(x: -5, y: 30) + ) + + check( + point: CGPoint(x: 10, y: 10), + // Rotate by 180º and then translate by (20, 15) + withTransform: CGAffineTransform.identity + .rotated(by: .pi) + .translatedBy(x: 20, y: 15), + mapsTo: CGPoint(x: 10, y: 5) + ) + } + + func testScalingRotation() { + check( + point: CGPoint(x: 20, y: 5), + // Scale by (0.5, 3) then rotate by -90º + withTransform: CGAffineTransform.identity + .scaledBy(x: 0.5, y: 3) + .rotated(by: .pi / -2), + mapsTo: CGPoint(x: 15, y: -10) + ) + + check( + point: CGPoint(x: 20, y: 5), + // Rotate by -90º the scale by (0.5, 3) + withTransform: CGAffineTransform.identity + .rotated(by: .pi / -2) + .scaledBy(x: 3, y: -0.5), + mapsTo: CGPoint(x: 15, y: 10) + ) + } +} + +// MARK: - Inversion + +extension CGAffineTransformTest { + func testInversion() { + let transforms = [ + CGAffineTransform(translationX: -30, y: 40), + CGAffineTransform(rotationAngle: .pi / 4), + CGAffineTransform(scaleX: 20, y: -10), + ] + + let composeTransform: CGAffineTransform = { + var transform = CGAffineTransform.identity + + for component in transforms { + transform = transform.concatenating(component) + } + + return transform + }() + + let recoveredIdentity: CGAffineTransform = { + var transform = composeTransform + + // Append inverse transformations in reverse order + for component in transforms.reversed() { + transform = transform.concatenating( + component.inverted() + ) + } + + return transform + }() + + check( + point: CGPoint(x: 10, y: 10), + withTransform: recoveredIdentity, + mapsTo: CGPoint(x: 10, y: 10) + ) + } +} + +// MARK: - Concatenation + +extension CGAffineTransformTest { + func testConcatenation() { + check( + point: CGPoint(x: 10, y: 15), + withTransform: CGAffineTransform.identity + .concatenating(.identity), + mapsTo: CGPoint(x: 10, y: 15) + ) + + check( + point: CGPoint(x: 10, y: 15), + // Scale by 2 then translate by (10, 0) + withTransform: CGAffineTransform(scaleX: 2, y: 2) + .concatenating(CGAffineTransform( + translationX: 10, y: 0 + )), + mapsTo: CGPoint(x: 30, y: 30) + ) + + check( + point: CGPoint(x: 10, y: 15), + // Translate by (10, 0) then scale by 2 + withTransform: CGAffineTransform(translationX: 10, y: 0) + .concatenating(CGAffineTransform(scaleX: 2, y: 2)), + mapsTo: CGPoint(x: 40, y: 30) + ) + } +} + +// MARK: - Coding + +extension CGAffineTransformTest { + func testCoding() throws { + let transform = CGAffineTransform( + a: 1, b: 2, + c: 3, d: 4, + tx: 5, ty: 6 + ) + + let encodedData = try JSONEncoder().encode(transform) + + let encodedString = String( + data: encodedData, encoding: .utf8 + ) + + let commaSeparatedNumbers = (1...6) + .map(String.init) + .joined(separator: ",") + + XCTAssertEqual( + encodedString, "[\(commaSeparatedNumbers)]", + "Invalid coding representation" + ) + + let recovered = try JSONDecoder().decode( + CGAffineTransform.self, from: encodedData + ) + + XCTAssertEqual( + transform, recovered, + "Encoded and then decoded transform does not equal original" + ) + } +}