Add state tests and improve testing API

This commit is contained in:
Carson Katri 2022-06-29 20:56:22 -04:00
parent 04ed169d33
commit 819b9cfa61
5 changed files with 261 additions and 114 deletions

View File

@ -85,7 +85,8 @@ public extension FiberReconciler {
/// Parent references are `unowned` (as opposed to `weak`) /// Parent references are `unowned` (as opposed to `weak`)
/// because the parent will always exist if a child does. /// because the parent will always exist if a child does.
/// If the parent is released, the child is released with it. /// If the parent is released, the child is released with it.
unowned var parent: Fiber? @_spi(TokamakCore)
public unowned var parent: Fiber?
/// The nearest parent that can be mounted on. /// The nearest parent that can be mounted on.
unowned var elementParent: Fiber? unowned var elementParent: Fiber?
@ -109,7 +110,8 @@ public extension FiberReconciler {
var geometry: ViewGeometry? var geometry: ViewGeometry?
/// The WIP node if this is current, or the current node if this is WIP. /// The WIP node if this is current, or the current node if this is WIP.
weak var alternate: Fiber? @_spi(TokamakCore)
public weak var alternate: Fiber?
var createAndBindAlternate: (() -> Fiber?)? var createAndBindAlternate: (() -> Fiber?)?

View File

@ -15,20 +15,23 @@
// Created by Carson Katri on 2/11/22. // Created by Carson Katri on 2/11/22.
// //
enum WalkWorkResult<Success> { @_spi(TokamakCore)
public enum WalkWorkResult<Success> {
case `continue` case `continue`
case `break`(with: Success) case `break`(with: Success)
case pause case pause
} }
enum WalkResult<Renderer: FiberRenderer, Success> { @_spi(TokamakCore)
public enum WalkResult<Renderer: FiberRenderer, Success> {
case success(Success) case success(Success)
case finished case finished
case paused(at: FiberReconciler<Renderer>.Fiber) case paused(at: FiberReconciler<Renderer>.Fiber)
} }
@_spi(TokamakCore)
@discardableResult @discardableResult
func walk<Renderer: FiberRenderer>( public func walk<Renderer: FiberRenderer>(
_ root: FiberReconciler<Renderer>.Fiber, _ root: FiberReconciler<Renderer>.Fiber,
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> Bool _ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> Bool
) rethrows -> WalkResult<Renderer, ()> { ) rethrows -> WalkResult<Renderer, ()> {
@ -38,7 +41,8 @@ func walk<Renderer: FiberRenderer>(
} }
/// Parent-first depth-first traversal of a `Fiber` tree. /// Parent-first depth-first traversal of a `Fiber` tree.
func walk<Renderer: FiberRenderer, Success>( @_spi(TokamakCore)
public func walk<Renderer: FiberRenderer, Success>(
_ root: FiberReconciler<Renderer>.Fiber, _ root: FiberReconciler<Renderer>.Fiber,
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> WalkWorkResult<Success> _ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> WalkWorkResult<Success>
) rethrows -> WalkResult<Renderer, Success> { ) rethrows -> WalkResult<Renderer, Success> {

View File

@ -177,7 +177,13 @@ public struct TestFiberRenderer: FiberRenderer {
} }
} }
/// The actively scheduled `DispatchWorkItem`
public static var workItem: DispatchWorkItem?
public func schedule(_ action: @escaping () -> ()) { public func schedule(_ action: @escaping () -> ()) {
action() let workItem = DispatchWorkItem {
action()
}
DispatchQueue.global(qos: .default).async(execute: workItem)
Self.workItem = workItem
} }
} }

View File

@ -0,0 +1,87 @@
// Copyright 2021 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.
//
// Created by Carson Katri on 6/29/22.
//
@_spi(TokamakCore)
import TokamakCore
import TokamakTestRenderer
import Dispatch
@dynamicMemberLookup
struct TestViewProxy<V: View> {
let id: AnyHashable
let reconciler: FiberReconciler<TestFiberRenderer>
var fiber: FiberReconciler<TestFiberRenderer>.Fiber? {
let id = AnyHashable(id)
let result = TokamakCore.walk(
reconciler.current
) { fiber -> WalkWorkResult<FiberReconciler<TestFiberRenderer>.Fiber?> in
guard case let .view(view, _) = fiber.content,
!(view is AnyOptional),
(view as? IdentifiedViewProtocol)?.id == AnyHashable(id),
let child = fiber.child
else { return WalkWorkResult.continue }
return WalkWorkResult.break(with: child)
}
guard case let .success(fiber) = result else { return nil }
return fiber
}
var view: V? {
guard case let .view(view, _) = fiber?.content else { return nil }
return view as? V
}
subscript<T>(dynamicMember member: KeyPath<V, T>) -> T? {
self.view?[keyPath: member]
}
}
protocol IdentifiedViewProtocol {
var id: AnyHashable { get }
}
struct IdentifiedView<Content: View>: View, IdentifiedViewProtocol {
let id: AnyHashable
let content: Content
var body: some View {
content
}
}
extension View {
func identified<ID: Hashable>(by id: ID) -> some View {
IdentifiedView(id: id, content: self)
}
}
extension FiberReconciler where Renderer == TestFiberRenderer {
func findView<ID: Hashable, V: View>(
id: ID,
as type: V.Type = V.self
) -> TestViewProxy<V> {
TestViewProxy<V>(id: id, reconciler: self)
}
/// Wait for the scheduled action to complete.
func turnRunLoop() {
TestFiberRenderer.workItem?.wait()
TestFiberRenderer.workItem = nil
}
}

View File

@ -64,16 +64,20 @@ final class VisitorTests: XCTestCase {
var body: some View { var body: some View {
VStack { VStack {
Text("\(count)") Text("\(count)")
.identified(by: "count")
HStack { HStack {
if count > 0 { if count > 0 {
Button("Decrement") { Button("Decrement") {
count -= 1 count -= 1
} }
.identified(by: "decrement")
} }
if count < 5 { if count < 5 {
Button("Increment") { Button("Increment") {
count += 1 count += 1
print(count)
} }
.identified(by: "increment")
} }
} }
} }
@ -84,75 +88,32 @@ final class VisitorTests: XCTestCase {
Counter() Counter()
} }
} }
let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500)) let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
.render(TestView())
var hStack: FiberReconciler<TestFiberRenderer>.Fiber? { reconciler.turnRunLoop()
reconciler.current // RootView
.child? // LayoutView let incrementButton = reconciler.findView(id: "increment", as: Button<Text>.self)
.child? // ModifiedContent let countText = reconciler.findView(id: "count", as: Text.self)
.child? // _ViewModifier_Content let decrementButton = reconciler.findView(id: "decrement", as: Button<Text>.self)
.child? // TestView
.child? // Counter
.child? // VStack
.child? // TupleView
.child?.sibling? // HStack
.child // TupleView
}
var text: FiberReconciler<TestFiberRenderer>.Fiber? {
reconciler.current // RootView
.child? // LayoutView
.child? // ModifiedContent
.child? // _ViewModifier_Content
.child? // TestView
.child? // Counter
.child? // VStack
.child? // TupleView
.child // Text
}
var decrementButton: FiberReconciler<TestFiberRenderer>.Fiber? {
hStack?
.child? // Optional
.child // Button
}
var incrementButton: FiberReconciler<TestFiberRenderer>.Fiber? {
hStack?
.child?.sibling? // Optional
.child // Button
}
func decrement() {
guard case let .view(view, _) = decrementButton?.content
else { return }
(view as? Button<Text>)?.action()
}
func increment() {
guard case let .view(view, _) = incrementButton?.content
else { return }
(view as? Button<Text>)?.action()
}
// The decrement button is removed when count is < 0 // The decrement button is removed when count is < 0
XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") XCTAssertNil(decrementButton.view, "'Decrement' should be hidden when count <= 0")
XCTAssertNotNil(incrementButton.view, "'Increment' should be visible when count < 5")
// Count up to 5 // Count up to 5
for i in 0..<5 { for i in 0..<5 {
reconciler.expect(text, equals: Text("\(i)")) XCTAssertEqual(countText.view, Text("\(i)"))
increment() incrementButton.action?()
reconciler.turnRunLoop()
} }
XCTAssertNil(incrementButton, "'Increment' should be hidden when count >= 5") XCTAssertNil(incrementButton.view, "'Increment' should be hidden when count >= 5")
reconciler.expect( XCTAssertNotNil(decrementButton.view, "'Decrement' should be visible when count > 0")
decrementButton,
represents: Button<Text>.self,
"'Decrement' should be visible when count > 0"
)
// Count down to 0. // Count down to 0.
for i in 0..<5 { for i in 0..<5 {
reconciler.expect(text, equals: Text("\(5 - i)")) XCTAssertEqual(countText.view, Text("\(5 - i)"))
decrement() decrementButton.action?()
reconciler.turnRunLoop()
} }
XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") XCTAssertNil(decrementButton.view, "'Decrement' should be hidden when count <= 0")
reconciler.expect( XCTAssertNotNil(incrementButton.view, "'Increment' should be visible when count < 5")
incrementButton,
represents: Button<Text>.self,
"'Increment' should be visible when count < 5"
)
} }
func testForEach() { func testForEach() {
@ -163,61 +124,148 @@ final class VisitorTests: XCTestCase {
var body: some View { var body: some View {
VStack { VStack {
Button("Add Item") { count += 1 } Button("Add Item") { count += 1 }
.identified(by: "addItem")
ForEach(Array(0..<count), id: \.self) { i in ForEach(Array(0..<count), id: \.self) { i in
Text("Item \(i)") Text("Item \(i)")
.identified(by: i)
} }
} }
} }
} }
let reconciler = TestFiberRenderer( let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
.root, reconciler.turnRunLoop()
size: .init(width: 500, height: 500),
useDynamicLayout: true let addItemButton = reconciler.findView(id: "addItem", as: Button<Text>.self)
) XCTAssertNotNil(addItemButton)
.render(TestView()) for i in 0..<10 {
var addItemFiber: FiberReconciler<TestFiberRenderer>.Fiber? { addItemButton.action?()
reconciler.current // RootView reconciler.turnRunLoop()
.child? // LayoutView XCTAssertEqual(reconciler.findView(id: i).view, Text("Item \(i)"))
.child? // ModifiedContent
.child? // _ViewModifier_Content
.child? // TestView
.child? // VStack
.child? // TupleView
.child // Button
} }
var forEachFiber: FiberReconciler<TestFiberRenderer>.Fiber? { }
reconciler.current // RootView
.child? // LayoutView func testDynamicProperties() {
.child? // ModifiedContent enum DynamicPropertyTest: Hashable {
.child? // _ViewModifier_Content case state
.child? // TestView case environment
.child? // VStack case stateObject
.child? // TupleView case observedObject
.child?.sibling // ForEach case environmentObject
} }
func item(at index: Int) -> FiberReconciler<TestFiberRenderer>.Fiber? { struct TestView: View {
var node = forEachFiber?.child var body: some View {
for _ in 0..<index { TestState()
node = node?.sibling TestEnvironment()
TestStateObject()
}
struct TestState: View {
@State
private var count = 0
var body: some View {
Button("\(count)") { count += 1 }
.identified(by: DynamicPropertyTest.state)
}
}
private enum TestKey: EnvironmentKey {
static let defaultValue = 5
}
struct TestEnvironment: View {
@Environment(\.self)
var values
var body: some View {
Text("\(values[TestKey.self])")
.identified(by: DynamicPropertyTest.environment)
}
}
struct TestStateObject: View {
final class Count: ObservableObject {
@Published
var count = 0
func increment() {
count += 5
}
}
@StateObject
private var count = Count()
var body: some View {
VStack {
Button("\(count.count)") {
count.increment()
}
.identified(by: DynamicPropertyTest.stateObject)
TestObservedObject(count: count)
TestEnvironmentObject()
}
.environmentObject(count)
}
struct TestObservedObject: View {
@ObservedObject
var count: Count
var body: some View {
Text("\(count.count)")
.identified(by: DynamicPropertyTest.observedObject)
}
}
struct TestEnvironmentObject: View {
@EnvironmentObject
var count: Count
var body: some View {
Text("\(count.count)")
.identified(by: DynamicPropertyTest.environmentObject)
}
}
} }
return node
} }
func addItem() {
guard case let .view(view, _) = addItemFiber?.content let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
else { return }
(view as? Button<Text>)?.action() reconciler.turnRunLoop()
}
reconciler.expect(addItemFiber, represents: Button<Text>.self) // State
reconciler.expect(forEachFiber, represents: ForEach<[Int], Int, Text>.self) let button = reconciler.findView(id: DynamicPropertyTest.state, as: Button<Text>.self)
addItem() XCTAssertEqual(button.label, Text("0"))
reconciler.expect(item(at: 0), equals: Text("Item 0")) button.action?()
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 2) reconciler.turnRunLoop()
addItem() XCTAssertEqual(button.label, Text("1"))
reconciler.expect(item(at: 1), equals: Text("Item 1"))
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 3) // Environment
addItem() XCTAssertEqual(
reconciler.expect(item(at: 2), equals: Text("Item 2")) reconciler.findView(id: DynamicPropertyTest.environment).view,
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 4) Text("5")
)
// StateObject
let stateObjectButton = reconciler.findView(
id: DynamicPropertyTest.stateObject,
as: Button<Text>.self
)
XCTAssertEqual(stateObjectButton.label, Text("0"))
stateObjectButton.action?()
reconciler.turnRunLoop()
XCTAssertEqual(stateObjectButton.label, Text("5"))
XCTAssertEqual(
reconciler.findView(id: DynamicPropertyTest.observedObject).view,
Text("5")
)
XCTAssertEqual(
reconciler.findView(id: DynamicPropertyTest.environmentObject).view,
Text("5")
)
} }
} }