Add state tests and improve testing API
This commit is contained in:
parent
04ed169d33
commit
819b9cfa61
|
@ -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?)?
|
||||||
|
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue