// Copyright © 2017 Schibsted. All rights reserved. import XCTest @testable import Layout private final class TestView: UIView { var wasUpdated = false @objc var testProperty = "" { didSet { wasUpdated = true } } } private final class TestViewController: UIViewController { var labelWasSet = false @objc weak var label: UILabel? { didSet { labelWasSet = true } } } final class LayoutNodeTests: XCTestCase { // MARK: Expression errors func testInvalidExpression() { let node = LayoutNode(expressions: ["foobar": "5"]) let errors = node.validate() XCTAssertEqual(errors.count, 1) XCTAssertTrue(errors.first?.description.contains("Unknown property") == true) XCTAssertTrue(errors.first?.description.contains("foobar") == true) } func testReadOnlyExpression() { let node = LayoutNode(expressions: ["safeAreaInsets.top": "5"]) let errors = node.validate() XCTAssertEqual(errors.count, 1) XCTAssertTrue(errors.first?.description.contains("read-only") == true) XCTAssertTrue(errors.first?.description.contains("safeAreaInsets.top") == true) } func testCircularReference() { let node = LayoutNode(expressions: ["top": "top"]) let errors = node.validate() XCTAssertEqual(errors.count, 1) XCTAssertTrue(errors.first?.description.contains("reference") == true) XCTAssertTrue(errors.first?.description.contains("top") == true) } func testMutualReferences() { let node = LayoutNode(expressions: ["top": "bottom", "bottom": "top"]) let errors = node.validate() XCTAssertGreaterThanOrEqual(errors.count, 2) for error in errors { let description = error.description XCTAssertTrue(description.contains("reference")) XCTAssertTrue(description.contains("top") || description.contains("bottom")) } } func testCircularMacroReference() throws { let xmlData = "".data(using: .utf8)! let node = try LayoutNode(xmlData: xmlData) let errors = node.validate() XCTAssertGreaterThanOrEqual(errors.count, 1) for error in errors { let description = error.description XCTAssertTrue(description.contains("reference")) XCTAssertTrue(description.contains("foo")) } } func testMutualMacroReferences() throws { let xmlData = "".data(using: .utf8)! let node = try LayoutNode(xmlData: xmlData) let errors = node.validate() XCTAssertGreaterThanOrEqual(errors.count, 1) for error in errors { let description = error.description XCTAssertTrue(description.contains("reference")) XCTAssertTrue(description.contains("foo") || description.contains("bar")) } } func testCircularReference3() { UIGraphicsBeginImageContext(CGSize(width: 20, height: 10)) let node = LayoutNode( expressions: [ "height": "auto", "width": "100%", ], children: [ LayoutNode( view: UIImageView(image: UIGraphicsGetImageFromCurrentImageContext()), expressions: [ "width": "max(auto, height)", "height": "max(auto, width)", ] ), ] ) UIGraphicsEndImageContext() let errors = node.validate() XCTAssertGreaterThanOrEqual(errors.count, 2) for error in errors { let description = error.description XCTAssertTrue(description.contains("reference")) XCTAssertTrue(description.contains("width") || description.contains("height")) } } // MARK: Invalid node errors func testUnknownClass() throws { let layout = try Layout(xmlData: XCTUnwrap("".data(using: .utf8))) XCTAssertThrowsError(try LayoutNode(layout: layout)) { error in XCTAssert("\(error)".contains("Unknown class Foo")) } } func testInvalidClass() throws { let layout = try Layout(xmlData: XCTUnwrap("".data(using: .utf8))) XCTAssertThrowsError(try LayoutNode(layout: layout)) { error in XCTAssert("\(error)".contains("NSObject is not a subclass of UIView")) } } // MARK: Animated setter func testSetSwitchStateAnimated() { let view = UISwitch() let node = LayoutNode(view: view, state: ["onState": false], expressions: ["isOn": "onState"]) XCTAssertFalse(view.isOn) node.setState(["onState": true], animated: true) XCTAssertTrue(view.isOn) } func testScrollViewZoomScaleAnimated() { let view = UIScrollView() let node = LayoutNode(view: view, state: ["zoom": 1], expressions: [ "zoomScale": "zoom", ]) node.setState(["zoom": 2], animated: true) } func testScrollViewContentOffsetAnimated() { let view = UIScrollView() let node = LayoutNode(view: view, state: ["offset": CGPoint.zero], expressions: [ "contentOffset": "offset", "contentSize.height": "100", ]) let expected = CGPoint(x: 0, y: 15) XCTAssertEqual(view.contentOffset, .zero) node.setState(["offset": expected], animated: true) XCTAssertEqual(view.contentOffset, expected) } // MARK: Property errors func testNonexistentViewProperty() { let node = LayoutNode(view: UIView(), expressions: ["width": "5 + layer.foobar"]) let errors = node.validate() XCTAssertEqual(errors.count, 1) XCTAssertTrue(errors.first?.description.contains("Unknown property") == true) XCTAssertTrue(errors.first?.description.contains("foobar") == true) } func testNestedNonexistentViewProperty() { let node = LayoutNode(view: UIView(), expressions: ["width": "5 + layer.foo.bar"]) let errors = node.validate() XCTAssertEqual(errors.count, 1) XCTAssertTrue(errors.first?.description.contains("Unknown property") == true) XCTAssertTrue(errors.first?.description.contains("foo.bar") == true) } func testNonexistentRectViewProperty() { let node = LayoutNode(view: UIView(), expressions: ["width": "5 + frame.foo.bar"]) let errors = node.validate() XCTAssertEqual(errors.count, 1) XCTAssertTrue(errors.first?.description.contains("Unknown property") == true) XCTAssertTrue(errors.first?.description.contains("foo.bar") == true) } func testNilViewProperty() { let node = LayoutNode(view: UIView(), expressions: ["width": "layer.contents == nil ? 5 : 10"]) let errors = node.validate() XCTAssertEqual(errors.count, 0) node.update() XCTAssertNil(node.view.layer.contents) XCTAssertEqual(node.view.frame.width, 5) } // MARK: State/constant/parameter shadowing func testExpressionShadowsConstant() { let node = LayoutNode(constants: ["top": 10], expressions: ["top": "top"]) let errors = node.validate() XCTAssertTrue(errors.isEmpty) XCTAssertEqual(try node.doubleValue(forSymbol: "top"), 10) } func testExpressionShadowsVariable() { let node = LayoutNode(state: ["top": 10], expressions: ["top": "top"]) let errors = node.validate() XCTAssertTrue(errors.isEmpty) XCTAssertEqual(try node.doubleValue(forSymbol: "top"), 10) } func testStateShadowsConstant() { let node = LayoutNode(state: ["foo": 10], constants: ["foo": 5], expressions: ["top": "foo"]) XCTAssertTrue(node.validate().isEmpty) XCTAssertEqual(try node.doubleValue(forSymbol: "foo"), 10) XCTAssertEqual(try node.doubleValue(forSymbol: "top"), 10) } func testConstantShadowsViewProperty() { let view = UIView() view.tag = 10 let node = LayoutNode(view: view, constants: ["tag": 5]) XCTAssertTrue(node.validate().isEmpty) XCTAssertEqual(try node.doubleValue(forSymbol: "tag"), 5) } func testStateShadowsInheritedConstant() { let child = LayoutNode(state: ["foo": 10], expressions: ["top": "foo"]) let parent = LayoutNode(constants: ["foo": 5], children: [child]) XCTAssertTrue(parent.validate().isEmpty) XCTAssertEqual(try child.doubleValue(forSymbol: "foo"), 10) XCTAssertEqual(try child.doubleValue(forSymbol: "top"), 10) } func testConstantShadowsInheritedState() { let child = LayoutNode(constants: ["foo": 10], expressions: ["top": "foo"]) let parent = LayoutNode(state: ["foo": 5], children: [child]) XCTAssertTrue(parent.validate().isEmpty) XCTAssertEqual(try child.doubleValue(forSymbol: "foo"), 10) XCTAssertEqual(try child.doubleValue(forSymbol: "top"), 10) } func testParameterNameShadowsState() throws { let xmlData = "".data(using: .utf8)! let node = try LayoutNode(xmlData: xmlData) node.setState(["name": "Foo"]) node.update() XCTAssertEqual((node.view as? UILabel)?.text, "Foo") } func testMacroNameShadowsState() throws { let xmlData = "".data(using: .utf8)! let node = try LayoutNode(xmlData: xmlData) node.setState(["name": "Foo"]) node.update() XCTAssertEqual((node.view.subviews[0] as? UILabel)?.text, "Foo") } func testMacroNameShadowsConstant() throws { let xmlData = "".data(using: .utf8)! let node = try LayoutNode(xmlData: xmlData) node.constants = ["foo": "bar"] let errors = node.validate() XCTAssert(errors.isEmpty) let label = node.children[0] XCTAssertEqual(try label.value(forSymbol: "text") as? String, "barbaz") XCTAssertEqual(try label.constantValue(forSymbol: "text") as? String, "barbaz") } // MARK: update(with:) func testUpdateViewWithSameClass() throws { let node = LayoutNode(view: UIView()) let oldView = node.view XCTAssertTrue(oldView.classForCoder == UIView.self) let layout = Layout(node) try node.update(with: layout) XCTAssertTrue(oldView === node.view) } func testUpdateViewWithSubclass() throws { let node = LayoutNode(view: UIView()) XCTAssertTrue(node.view.classForCoder == UIView.self) let layout = Layout(LayoutNode(view: UILabel())) try node.update(with: layout) XCTAssertTrue(node.view.classForCoder == UILabel.self) } func testUpdateViewWithSuperclass() { let node = LayoutNode(view: UILabel()) let layout = Layout(LayoutNode(view: UIView())) XCTAssertThrowsError(try node.update(with: layout)) } func testUpdateViewControllerWithSameClass() throws { let node = LayoutNode(viewController: UIViewController()) let oldViewController = node.viewController XCTAssertTrue(oldViewController?.classForCoder == UIViewController.self) let layout = Layout(node) try node.update(with: layout) XCTAssertTrue(oldViewController === node.viewController) } func testUpdateViewControllerWithSubclass() throws { let node = LayoutNode(viewController: UIViewController()) XCTAssertTrue(node.viewController?.classForCoder == UIViewController.self) let layout = Layout(LayoutNode(viewController: UITabBarController())) try node.update(with: layout) XCTAssertTrue(node.viewController?.classForCoder == UITabBarController.self) } func testUpdateViewControllerWithSuperclass() { let node = LayoutNode(viewController: UITabBarController()) let layout = Layout(LayoutNode(viewController: UIViewController())) XCTAssertThrowsError(try node.update(with: layout)) } // MARK: value persistence func testLiteralValueNotReapplied() { let view = TestView() let node = LayoutNode(view: view, expressions: ["testProperty": "foo"]) node.update() XCTAssertTrue(view.wasUpdated) XCTAssertEqual(view.testProperty, "foo") view.wasUpdated = false node.update() XCTAssertFalse(view.wasUpdated) view.testProperty = "bar" node.update() XCTAssertEqual(view.testProperty, "bar") } func testConstantValueNotReapplied() { let view = TestView() let node = LayoutNode(view: view, constants: ["foo": "foo"], expressions: ["testProperty": "{foo}"]) node.update() XCTAssertTrue(view.wasUpdated) XCTAssertEqual(view.testProperty, "foo") view.wasUpdated = false node.update() XCTAssertFalse(view.wasUpdated) view.testProperty = "bar" node.update() XCTAssertEqual(view.testProperty, "bar") } func testUnchangedValueNotReapplied() { let view = TestView() let node = LayoutNode(view: view, state: ["text": "foo"], expressions: ["testProperty": "{text}"]) node.update() XCTAssertTrue(view.wasUpdated) XCTAssertEqual(view.testProperty, "foo") view.wasUpdated = false node.update() XCTAssertFalse(view.wasUpdated) } // MARK: property evaluation order func testUpdateContentInsetWithTop() { let scrollView = UIScrollView() let node = LayoutNode( view: scrollView, state: [ "inset": UIEdgeInsets(), "insetTop": 5, ], expressions: [ "contentInset": "inset", "contentInset.top": "insetTop", ] ) node.update() XCTAssertEqual(scrollView.contentInset.top, 5) } func testUpdateContentInsetWithConstantTop() { let scrollView = UIScrollView() let node = LayoutNode( view: scrollView, state: ["inset": UIEdgeInsets()], expressions: [ "contentInset": "inset", "contentInset.top": "5", ] ) node.update() XCTAssertEqual(scrollView.contentInset.top, 5) } // MARK: outlet expressions func testOutletBinding() { let node = LayoutNode( children: [ LayoutNode( view: UILabel(), outlet: "label" ), ] ) let viewController = TestViewController() XCTAssertNoThrow(try node.mount(in: viewController)) XCTAssertTrue(viewController.labelWasSet) } func testOutletConstantBinding() { let node = LayoutNode( constants: [ "label.outlet": "label", ], children: [ LayoutNode( view: UILabel(), outlet: "{label.outlet}" ), ] ) let viewController = TestViewController() XCTAssertNoThrow(try node.mount(in: viewController)) XCTAssertTrue(viewController.labelWasSet) } func testOutletConstantExpressionBinding() { let node = LayoutNode( view: UILabel(), constants: [ "label.outlet": "label", ], expressions: [ "text": "{label.outlet}", ], children: [ LayoutNode( view: UILabel(), outlet: "{parent.text}" ), ] ) let viewController = TestViewController() XCTAssertNoThrow(try node.mount(in: viewController)) XCTAssertTrue(viewController.labelWasSet) } func testOutletVariableBinding() { let node = LayoutNode( state: [ "label.outlet": "label", ], children: [ LayoutNode( view: UILabel(), outlet: "{label.outlet}" ), ] ) let viewController = TestViewController() XCTAssertThrowsError(try node.mount(in: viewController)) { error in XCTAssert("\(error)".contains("must be a constant or literal value")) } } // MARK: node lookup func testFindChild() { let child = LayoutNode(id: "bar") let parent = LayoutNode(id: "foo", children: [child]) XCTAssertEqual(parent.childNode(withID: "bar"), child) XCTAssertEqual(parent.children(withID: "bar"), [child]) parent.update() XCTAssertEqual(parent.node(withID: "bar"), child) } func testFindChildren() { let child1 = LayoutNode(id: "bar") let child2 = LayoutNode(id: "bar") let parent = LayoutNode(id: "foo", children: [child1, child2]) XCTAssertEqual(parent.childNode(withID: "bar"), child1) XCTAssertEqual(parent.children(withID: "bar"), [child1, child2]) parent.update() XCTAssertEqual(parent.node(withID: "bar"), child1) } func testFindGrandchild() { let grandchild = LayoutNode(id: "baz") let child = LayoutNode(id: "bar", children: [grandchild]) let parent = LayoutNode(id: "foo", children: [child]) XCTAssertEqual(parent.childNode(withID: "baz"), grandchild) XCTAssertEqual(parent.children(withID: "baz"), [grandchild]) parent.update() XCTAssertEqual(parent.node(withID: "baz"), grandchild) } func testFindSelf() { let node = LayoutNode(id: "foo") XCTAssertNil(node.childNode(withID: "foo")) XCTAssert(node.children(withID: "foo").isEmpty) node.update() XCTAssertEqual(node.node(withID: "foo"), node) } func testFindParent() { let child = LayoutNode(id: "bar") let parent = LayoutNode(id: "foo", children: [child]) parent.update() XCTAssertNil(child.childNode(withID: "foo")) XCTAssert(child.children(withID: "foo").isEmpty) XCTAssertEqual(parent.children[0], child) XCTAssertEqual(child.node(withID: "foo"), parent) } func testFindGrandparent() { let grandchild = LayoutNode(id: "baz") let child = LayoutNode(id: "bar", children: [grandchild]) let parent = LayoutNode(id: "foo", children: [child]) parent.update() XCTAssertEqual(grandchild.node(withID: "foo"), parent) } func testFindSiblings() { let bar = LayoutNode(id: "bar") let baz = LayoutNode(id: "baz") let parent = LayoutNode(id: "foo", children: [bar, baz]) parent.update() XCTAssertNil(bar.childNode(withID: "baz")) XCTAssert(bar.children(withID: "baz").isEmpty) XCTAssertEqual(bar.next, baz) XCTAssertNil(bar.previous) XCTAssertEqual(baz.previous, bar) XCTAssertNil(baz.next) XCTAssertEqual(parent.children[0], bar) XCTAssertEqual(parent.children[1], baz) XCTAssertEqual(bar.node(withID: "baz"), baz) } func testFindCousin() { let bar = LayoutNode(id: "bar") let baz = LayoutNode(id: "baz") let parent = LayoutNode(id: "foo", children: [ LayoutNode(children: [bar]), LayoutNode(children: [baz]), ]) parent.update() XCTAssertEqual(bar.node(withID: "baz"), baz) } func testFindNonexistentNode() { let child = LayoutNode() let parent = LayoutNode(id: "foo", children: [child]) XCTAssertNil(parent.childNode(withID: "bar")) XCTAssert(parent.children(withID: "bar").isEmpty) parent.update() XCTAssertNil(parent.node(withID: "bar")) } // MARK: memory leaks func testLayoutNodeDoesNotRetainItself() throws { weak var controller: UIViewController? weak var view: UIView? weak var node: LayoutNode? try autoreleasepool { let vc = UIViewController() controller = vc let _node = LayoutNode(view: UIView.self) node = _node view = _node.view XCTAssertNotNil(view) try _node.mount(in: vc) } XCTAssertNil(controller) XCTAssertNil(view) XCTAssertNil(node) } func testLayoutTreeDoesNotContainCycles() throws { weak var root: LayoutNode? weak var child: LayoutNode? try autoreleasepool { let vc = UIViewController() let _node = LayoutNode(view: UIView.self, children: [ LayoutNode(view: UILabel.self, expressions: [ "attributedText": "Hello World", ]), ]) root = _node child = _node.children.first try _node.mount(in: vc) } XCTAssertNil(root) XCTAssertNil(child) } func testLayoutWhereChildReferencesParentIsReleased() { let child = LayoutNode(id: "bar", expressions: [ "backgroundColor": "parent.backgroundColor", "contentMode": "#foo.contentMode", ]) var strongParent: LayoutNode? = LayoutNode(id: "foo", children: [child]) weak var parent: LayoutNode? = strongParent parent?.update() strongParent = nil XCTAssertNil(parent) } func testLayoutNodeWithSelfReferencingExpressionIsReleased() { weak var node: LayoutNode? do { let strongNode = LayoutNode( view: UIView(), expressions: [ "top": "safeAreaInsets.top", ] ) strongNode.update() node = strongNode } XCTAssertNil(node) } // MARK: empty expressions func testHasExpression() { let node = LayoutNode(expressions: ["backgroundColor": "red"]) XCTAssertTrue(node.hasExpression("backgroundColor")) } func testDoesntHaveExpression() { let node = LayoutNode(expressions: ["backgroundColor": "//red"]) XCTAssertFalse(node.hasExpression("backgroundColor")) } func testDoesntHaveDefaultExpression() { let node = LayoutNode(expressions: ["width": "//5", "left": "4", "right": "6"]) XCTAssertFalse(node.hasExpression("width")) XCTAssertEqual(try node.doubleValue(forSymbol: "width"), 2.0) } }