diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 14117275..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: [nalexn] -custom: ["https://venmo.com/nallexn"] diff --git a/Sources/ViewInspector/BaseTypes.swift b/Sources/ViewInspector/BaseTypes.swift index 2e54f237..1816b19c 100644 --- a/Sources/ViewInspector/BaseTypes.swift +++ b/Sources/ViewInspector/BaseTypes.swift @@ -43,7 +43,7 @@ extension SupplementaryChildrenLabelView { } @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) -public protocol KnownViewType { +public protocol BaseViewType { static var typePrefix: String { get } static var namespacedPrefixes: [String] { get } static var isTransitive: Bool { get } @@ -51,7 +51,7 @@ public protocol KnownViewType { } @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) -public extension KnownViewType { +public extension BaseViewType { static var namespacedPrefixes: [String] { guard !typePrefix.isEmpty else { return [] } return [.swiftUINamespaceRegex + typePrefix] diff --git a/Sources/ViewInspector/ContentExtraction.swift b/Sources/ViewInspector/ContentExtraction.swift index 4ff2669f..be5e594e 100644 --- a/Sources/ViewInspector/ContentExtraction.swift +++ b/Sources/ViewInspector/ContentExtraction.swift @@ -8,8 +8,7 @@ internal struct ContentExtractor { } internal func extractContent(environmentObjects: [AnyObject]) throws -> Any { - try validateSource() - + try validateSourceBeforeExtraction() switch contentSource { case .view(let view): return try view.extractContent(environmentObjects: environmentObjects) @@ -29,6 +28,9 @@ internal struct ContentExtractor { } return .view(view) case let viewModifier as any ViewModifier: + guard viewModifier.hasBody else { + throw InspectionError.notSupported("ViewModifier without the body") + } return .viewModifier(viewModifier) case let gesture as any Gesture: return .gesture(gesture) @@ -38,7 +40,7 @@ internal struct ContentExtractor { } } - private func validateSource() throws { + private func validateSourceBeforeExtraction() throws { switch contentSource.source { #if os(macOS) case is any NSViewRepresentable: @@ -98,6 +100,16 @@ internal struct ContentExtractor { private let contentSource: ContentSource } +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +private extension ViewModifier { + var hasBody: Bool { + if self is (any EnvironmentalModifier) { + return true + } + return Body.self != Never.self + } +} + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) public extension View { @@ -126,6 +138,10 @@ public extension ViewModifier { throw InspectionError .missingEnvironmentObjects(view: view, objects: missingObjects) } + if let envModifier = copy as? (any EnvironmentalModifier) { + let resolved = envModifier.resolve(in: EnvironmentValues()) + return try resolved.extractContent(environmentObjects: environmentObjects) + } return copy.body() } } diff --git a/Sources/ViewInspector/InspectableView.swift b/Sources/ViewInspector/InspectableView.swift index 0456ad85..76871be7 100644 --- a/Sources/ViewInspector/InspectableView.swift +++ b/Sources/ViewInspector/InspectableView.swift @@ -2,7 +2,7 @@ import SwiftUI import XCTest @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) -public struct InspectableView where View: KnownViewType { +public struct InspectableView where View: BaseViewType { internal let content: Content internal let parentView: UnwrappedView? @@ -108,14 +108,14 @@ internal extension UnwrappedView { return try .init(content, parent: parentView, call: inspectionCall, index: inspectionIndex) } - func asInspectableView(ofType type: T.Type) throws -> InspectableView where T: KnownViewType { + func asInspectableView(ofType type: T.Type) throws -> InspectableView where T: BaseViewType { return try .init(content, parent: parentView, call: inspectionCall, index: inspectionIndex) } } @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) internal extension InspectableView { - func asInspectableView(ofType type: T.Type) throws -> InspectableView where T: KnownViewType { + func asInspectableView(ofType type: T.Type) throws -> InspectableView where T: BaseViewType { return try .init(content, parent: parentView, call: inspectionCall, index: inspectionIndex) } } diff --git a/Sources/ViewInspector/Inspector.swift b/Sources/ViewInspector/Inspector.swift index ead3d09a..9f314970 100644 --- a/Sources/ViewInspector/Inspector.swift +++ b/Sources/ViewInspector/Inspector.swift @@ -41,6 +41,21 @@ internal extension Inspector { return casted } + static func unsafeMemoryRebind(value: V, type: T.Type) throws -> T { + guard MemoryLayout.size == MemoryLayout.size else { + throw InspectionError.notSupported( + """ + Unable to rebind value of type \(Inspector.typeName(value: value, namespaced: true)) \ + to \(Inspector.typeName(type: type, namespaced: true)). \ + This is an internal library error, please open a ticket with these details. + """) + } + return withUnsafeBytes(of: value) { bytes in + return bytes.baseAddress! + .assumingMemoryBound(to: T.self).pointee + } + } + enum GenericParameters { case keep case remove @@ -66,7 +81,7 @@ internal extension Inspector { private static func isSystemType(name: String) -> Bool { return [ - String.swiftUINamespaceRegex, + String.swiftUINamespaceRegex, "Swift\\.", "_CoreLocationUI_SwiftUI\\.", "_MapKit_SwiftUI\\.", "_AuthenticationServices_SwiftUI\\.", "_AVKit_SwiftUI\\.", ].containsPrefixRegex(matching: name, wholeMatch: false) @@ -91,7 +106,7 @@ internal extension Inspector { private extension String { func sanitizingNamespace() -> String { var str = self - if let range = str.range(of: ".(unknown context at ") { + while let range = str.range(of: ".(unknown context at ") { let end = str.index(range.upperBound, offsetBy: .init(11)) str.replaceSubrange(range.lowerBound.. Content { + return try children(content).element(at: 0) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension ViewType.NavigationLink: MultipleViewContent { + + public static func children(_ content: Content) throws -> LazyGroup { + if let isActive = try? content.isActive(), !isActive { + throw InspectionError.viewNotFound(parent: "NavigationLink's destination") + } let view = try Inspector.attribute(label: "destination", value: content.view) - let medium = content.medium.resettingViewModifiers() - return try Inspector.unwrap(view: view, medium: medium) + return try Inspector.viewsInContainer(view: view, medium: content.medium) } } @@ -56,48 +66,52 @@ public extension InspectableView where View == ViewType.NavigationLink { } func isActive() throws -> Bool { - guard let external = try isActiveBinding() else { - return try isActiveState().wrappedValue - } - return external.wrappedValue + return try content.isActive() } - func activate() throws { try set(isActive: true) } - - func deactivate() throws { try set(isActive: false) } + func activate() throws { + try content.set(isActive: true) + } - private func set(isActive: Bool) throws { - if let external = try isActiveBinding() { - external.wrappedValue = isActive - } else { - // @State mutation from outside is ignored by SwiftUI - // try isActiveState().wrappedValue = isActive - // swiftlint:disable line_length - throw InspectionError.notSupported("Enable programmatic navigation by using `NavigationLink(destination:, tag:, selection:)`") - // swiftlint:enable line_length - } + func deactivate() throws { + try content.set(isActive: false) } } // MARK: - Private @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) -private extension InspectableView where View == ViewType.NavigationLink { - func isActiveState() throws -> State { - if #available(iOS 14, tvOS 14, macOS 10.16, *) { - return try Inspector - .attribute(path: "_isActive|state", value: content.view, type: State.self) +private extension Content { + + func isActive() throws -> Bool { + if let binding = try? isActiveBinding() { + return binding.wrappedValue } - return try Inspector - .attribute(label: "__internalIsActive", value: content.view, type: State.self) + return try isActiveState().wrappedValue } - func isActiveBinding() throws -> Binding? { + func set(isActive: Bool) throws { + if let binding = try? isActiveBinding() { + binding.wrappedValue = isActive + return + } + try isActiveState().wrappedValue = isActive + } + + private func isActiveState() throws -> State { + throw InspectionError.notSupported( + """ + Please use `NavigationLink(destination:, tag:, selection:)` \ + if you need to access the state value for reading or writing. + """) + } + + private func isActiveBinding() throws -> Binding { if #available(iOS 14, tvOS 14, macOS 10.16, *) { - return try? Inspector - .attribute(path: "_isActive|binding", value: content.view, type: Binding.self) + return try Inspector + .attribute(path: "_isActive|binding", value: view, type: Binding.self) } - return try? Inspector - .attribute(label: "_externalIsActive", value: content.view, type: Binding.self) + return try Inspector + .attribute(label: "_externalIsActive", value: view, type: Binding.self) } } diff --git a/Sources/ViewInspector/SwiftUI/NavigationSplitView.swift b/Sources/ViewInspector/SwiftUI/NavigationSplitView.swift new file mode 100644 index 00000000..8f1b0b9a --- /dev/null +++ b/Sources/ViewInspector/SwiftUI/NavigationSplitView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +public extension ViewType { + + struct NavigationSplitView: KnownViewType { + public static var typePrefix: String = "NavigationSplitView" + } +} + +// MARK: - Content Extraction + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension ViewType.NavigationSplitView: SingleViewContent { + + public static func child(_ content: Content) throws -> Content { + return try children(content).element(at: 0) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension ViewType.NavigationSplitView: MultipleViewContent { + + public static func children(_ content: Content) throws -> LazyGroup { + let view = try Inspector.attribute(label: "content", value: content.view) + return try Inspector.viewsInContainer(view: view, medium: content.medium) + } +} + +// MARK: - Extraction from SingleViewContent parent + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public extension InspectableView where View: SingleViewContent { + + func navigationSplitView() throws -> InspectableView { + return try .init(try child(), parent: self) + } +} + +// MARK: - Extraction from MultipleViewContent parent + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public extension InspectableView where View: MultipleViewContent { + + func navigationSplitView(_ index: Int) throws -> InspectableView { + return try .init(try child(at: index), parent: self, index: index) + } +} + +// MARK: - Non Standard Children + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension ViewType.NavigationSplitView: SupplementaryChildren { + static func supplementaryChildren(_ parent: UnwrappedView) throws -> LazyGroup { + return .init(count: 2) { index in + let medium = parent.content.medium.resettingViewModifiers() + if index == 0 { + let child = try Inspector.attribute(label: "detail", value: parent.content.view) + let content = try Inspector.unwrap(content: Content(child, medium: medium)) + return try InspectableView( + content, parent: parent, call: "detailView()") + } else { + let child = try Inspector.attribute(label: "sidebar", value: parent.content.view) + let content = try Inspector.unwrap(content: Content(child, medium: medium)) + return try InspectableView( + content, parent: parent, call: "sidebarView()") + } + } + } +} + +// MARK: - Custom Attributes + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public extension InspectableView where View == ViewType.NavigationSplitView { + + func detailView() throws -> InspectableView { + return try View.supplementaryChildren(self).element(at: 0) + .asInspectableView(ofType: ViewType.ClassifiedView.self) + } + + func sidebarView() throws -> InspectableView { + return try View.supplementaryChildren(self).element(at: 1) + .asInspectableView(ofType: ViewType.ClassifiedView.self) + } +} diff --git a/Sources/ViewInspector/SwiftUI/NavigationStack.swift b/Sources/ViewInspector/SwiftUI/NavigationStack.swift new file mode 100644 index 00000000..c93de538 --- /dev/null +++ b/Sources/ViewInspector/SwiftUI/NavigationStack.swift @@ -0,0 +1,48 @@ +import SwiftUI + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +public extension ViewType { + + struct NavigationStack: KnownViewType { + public static var typePrefix: String = "NavigationStack" + } +} + +// MARK: - Content Extraction + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension ViewType.NavigationStack: SingleViewContent { + + public static func child(_ content: Content) throws -> Content { + return try children(content).element(at: 0) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension ViewType.NavigationStack: MultipleViewContent { + + public static func children(_ content: Content) throws -> LazyGroup { + let view = try Inspector.attribute(label: "root", value: content.view) + return try Inspector.viewsInContainer(view: view, medium: content.medium) + } +} + +// MARK: - Extraction from SingleViewContent parent + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public extension InspectableView where View: SingleViewContent { + + func navigationStack() throws -> InspectableView { + return try .init(try child(), parent: self) + } +} + +// MARK: - Extraction from MultipleViewContent parent + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +public extension InspectableView where View: MultipleViewContent { + + func navigationStack(_ index: Int) throws -> InspectableView { + return try .init(try child(at: index), parent: self, index: index) + } +} diff --git a/Sources/ViewInspector/SwiftUI/NavigationView.swift b/Sources/ViewInspector/SwiftUI/NavigationView.swift index e0d7569d..2308c60f 100644 --- a/Sources/ViewInspector/SwiftUI/NavigationView.swift +++ b/Sources/ViewInspector/SwiftUI/NavigationView.swift @@ -10,6 +10,14 @@ public extension ViewType { // MARK: - Content Extraction +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension ViewType.NavigationView: SingleViewContent { + + public static func child(_ content: Content) throws -> Content { + return try children(content).element(at: 0) + } +} + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) extension ViewType.NavigationView: MultipleViewContent { diff --git a/Sources/ViewInspector/SwiftUI/Sheet.swift b/Sources/ViewInspector/SwiftUI/Sheet.swift index 5119d4ca..4499fe27 100644 --- a/Sources/ViewInspector/SwiftUI/Sheet.swift +++ b/Sources/ViewInspector/SwiftUI/Sheet.swift @@ -11,6 +11,7 @@ public extension ViewType { public static func inspectionCall(typeName: String) -> String { return "\(typeName.firstLetterLowercased)(\(ViewType.indexPlaceholder))" } + public static var genericViewTypeForViewSearch: String? { "Sheet" } } typealias FullScreenCover = Sheet } diff --git a/Sources/ViewInspector/SwiftUI/SignInWithAppleButton.swift b/Sources/ViewInspector/SwiftUI/SignInWithAppleButton.swift index 54692983..47294dda 100644 --- a/Sources/ViewInspector/SwiftUI/SignInWithAppleButton.swift +++ b/Sources/ViewInspector/SwiftUI/SignInWithAppleButton.swift @@ -90,10 +90,7 @@ public extension InspectableView where View == ViewType.SignInWithAppleButton { private typealias ButtonSurrogate = SignInWithAppleButton.Surrogate private func buttonSurrogate() throws -> ButtonSurrogate { let button = try Inspector.cast(value: content.view, type: SignInWithAppleButton.self) - return withUnsafeBytes(of: button) { bytes in - return bytes.baseAddress! - .assumingMemoryBound(to: ButtonSurrogate.self).pointee - } + return try Inspector.unsafeMemoryRebind(value: button, type: ButtonSurrogate.self) } } diff --git a/Sources/ViewInspector/SwiftUI/TabView.swift b/Sources/ViewInspector/SwiftUI/TabView.swift index 6e6098fc..eacdecc7 100644 --- a/Sources/ViewInspector/SwiftUI/TabView.swift +++ b/Sources/ViewInspector/SwiftUI/TabView.swift @@ -15,7 +15,17 @@ extension ViewType.TabView: MultipleViewContent { public static func children(_ content: Content) throws -> LazyGroup { let view = try Inspector.attribute(label: "content", value: content.view) - return try Inspector.viewsInContainer(view: view, medium: content.medium) + let children = try Inspector.viewsInContainer(view: view, medium: content.medium) + guard let selectedValue = content.tabViewSelectionValue() else { + return children + } + return .init(count: children.count) { index in + let child = try children.element(at: index) + if let viewTag = try? InspectableView(child, parent: nil).tag(), viewTag != selectedValue { + throw InspectionError.viewNotFound(parent: "tab with tag \(viewTag)") + } + return child + } } } @@ -98,6 +108,11 @@ internal extension Content { return view } } + + fileprivate func tabViewSelectionValue() -> AnyHashable? { + let valueProvider = try? Inspector.cast(value: view, type: SelectionValueProvider.self) + return valueProvider?.selectionValue() + } } @available(iOS 14.0, tvOS 14.0, watchOS 7.0, *) @@ -124,3 +139,16 @@ extension PageTabViewStyle.IndexDisplayMode: Equatable { return String(describing: lhsBacking) == String(describing: rhsBacking) } } + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +internal protocol SelectionValueProvider { + func selectionValue() -> AnyHashable? +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension TabView: SelectionValueProvider { + func selectionValue() -> AnyHashable? { + let binding = try? Inspector.attribute(label: "selection", value: self, type: Binding?.self) + return binding?.wrappedValue + } +} diff --git a/Sources/ViewInspector/SwiftUI/TimelineView.swift b/Sources/ViewInspector/SwiftUI/TimelineView.swift index 1cd2488b..2f3d50dc 100644 --- a/Sources/ViewInspector/SwiftUI/TimelineView.swift +++ b/Sources/ViewInspector/SwiftUI/TimelineView.swift @@ -59,8 +59,7 @@ public extension InspectableView where View == ViewType.TimelineView { func contentView(_ context: ViewType.TimelineView.Context = .init() ) throws -> InspectableView { - let contextCopy = context - return try ViewType.TimelineView.view(for: contextCopy, parent: self) + return try ViewType.TimelineView.view(for: context, parent: self) } } @@ -72,12 +71,25 @@ public extension ViewType.TimelineView { public enum Cadence { case live, seconds, minutes } - let date: Date - let cadence: Cadence + public let date: Date + public let cadence: Cadence + public init(date: Date = Date(), cadence: Cadence = .live) { self.date = date self.cadence = cadence } + + fileprivate var context32: Context32 { Context32(context: self) } + } + fileprivate struct Context32 { + private let date: Date + private let cadence: Context.Cadence + private let filler: (Int64, Int64) = (0, 0) + + init(context: Context) { + self.date = context.date + self.cadence = context.cadence + } } } @@ -89,10 +101,18 @@ extension TimelineView: ElementViewProvider { label: "content", value: self, type: Builder.self) let param = try Inspector.cast( value: element, type: ViewType.TimelineView.Context.self) - let context = withUnsafeBytes(of: param) { bytes in - return bytes.baseAddress! - .assumingMemoryBound(to: Context.self).pointee - } + let context = try Self.adapt(context: param) return builder(context) } + + static func adapt(context: ViewType.TimelineView.Context) throws -> Context { + switch MemoryLayout.size { + case 9: + return try Inspector.unsafeMemoryRebind(value: context, type: Context.self) + case 32: + return try Inspector.unsafeMemoryRebind(value: context.context32, type: Context.self) + default: + fatalError(MemoryLayout.actualSize()) + } + } } diff --git a/Sources/ViewInspector/ViewSearch.swift b/Sources/ViewInspector/ViewSearch.swift index c176a285..38109f83 100644 --- a/Sources/ViewInspector/ViewSearch.swift +++ b/Sources/ViewInspector/ViewSearch.swift @@ -220,7 +220,7 @@ public extension InspectableView { func find(_ viewType: T.Type, containing string: String, locale: Locale = .testsDefault - ) throws -> InspectableView where T: KnownViewType { + ) throws -> InspectableView where T: BaseViewType { return try find(ViewType.Text.self, where: { text in try text.string(locale: locale) == string && (try? text.find(T.self, relation: .parent)) != nil @@ -244,7 +244,7 @@ public extension InspectableView { traversal: ViewSearch.Traversal = .breadthFirst, skipFound: Int = 0, where condition: (InspectableView) throws -> Bool = { _ in true } - ) throws -> InspectableView where T: KnownViewType { + ) throws -> InspectableView where T: BaseViewType { let view = try find(relation: relation, traversal: traversal, skipFound: skipFound, where: { view -> Bool in let typedView = try view.asInspectableView(ofType: T.self) return try condition(typedView) @@ -305,7 +305,7 @@ public extension InspectableView { */ func findAll(_ viewType: T.Type, where condition: (InspectableView) throws -> Bool = { _ in true } - ) -> [InspectableView] where T: KnownViewType { + ) -> [InspectableView] where T: BaseViewType { return findAll(where: { view in guard let typedView = try? view.asInspectableView(ofType: T.self) else { return false } @@ -408,7 +408,9 @@ private extension UnwrappedView { while !queue.isEmpty { let (isSingle, children) = queue.remove(at: 0) for offset in 0.. Bool { + guard content.isCustomView else { return true } + let typeRef = type(of: content.view) + return (try? findParent(condition: { parent in + return typeRef == type(of: parent.content.view) && parent.parentView != nil + }, skipFound: 0)) == nil + } +} + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) private extension ViewSearch.Traversal { func search(in view: UnwrappedView, diff --git a/Sources/ViewInspector/ViewSearchIndex.swift b/Sources/ViewInspector/ViewSearchIndex.swift index d4410d06..756a3601 100644 --- a/Sources/ViewInspector/ViewSearchIndex.swift +++ b/Sources/ViewInspector/ViewSearchIndex.swift @@ -6,60 +6,89 @@ import SwiftUI internal extension ViewSearch { private static var index: [String: [ViewIdentity]] = { - let identities: [ViewIdentity] = [ - .init(ViewType.ActionSheet.self), - .init(ViewType.Alert.self), .init(ViewType.AlertButton.self), - .init(ViewType.AngularGradient.self), .init(ViewType.AnyView.self), - .init(ViewType.AsyncImage.self), - .init(ViewType.Button.self), .init(ViewType.Canvas.self), - .init(ViewType.Color.self), .init(ViewType.ColorPicker.self), - .init(ViewType.ConfirmationDialog.self), - .init(ViewType.ControlGroup.self, genericTypeName: nil), - .init(ViewType.DatePicker.self), .init(ViewType.DisclosureGroup.self), - .init(ViewType.Divider.self), - .init(ViewType.EditButton.self), .init(ViewType.EmptyView.self), - .init(ViewType.EllipticalGradient.self), - .init(ViewType.ForEach.self), .init(ViewType.Form.self), - .init(ViewType.GeometryReader.self), - .init(ViewType.Group.self), .init(ViewType.GroupBox.self), - .init(ViewType.HSplitView.self), .init(ViewType.HStack.self), - .init(ViewType.Image.self), - .init(ViewType.Label.self), - .init(ViewType.LazyHGrid.self), .init(ViewType.LazyHStack.self), - .init(ViewType.LazyVGrid.self), .init(ViewType.LazyVStack.self), - .init(ViewType.LinearGradient.self), - .init(ViewType.Link.self), .init(ViewType.List.self), - .init(ViewType.LocationButton.self), - .init(ViewType.Map.self), - .init(ViewType.Menu.self), .init(ViewType.MenuButton.self), - .init(ViewType.NavigationLink.self), .init(ViewType.NavigationView.self), - .init(ViewType.OutlineGroup.self), - .init(ViewType.PasteButton.self), .init(ViewType.Picker.self), - .init(ViewType.Popover.self, genericTypeName: nil), - .init(ViewType.ProgressView.self), - .init(ViewType.RadialGradient.self), - .init(ViewType.SafeAreaInset.self, genericTypeName: nil), - .init(ViewType.ScrollView.self), .init(ViewType.ScrollViewReader.self), - .init(ViewType.Section.self), .init(ViewType.SecureField.self), - .init(ViewType.SignInWithAppleButton.self), - .init(ViewType.Sheet.self, genericTypeName: "Sheet"), - .init(ViewType.Slider.self), .init(ViewType.Spacer.self), .init(ViewType.Stepper.self), - .init(ViewType.StyleConfiguration.Label.self), .init(ViewType.StyleConfiguration.Content.self), - .init(ViewType.StyleConfiguration.Title.self), .init(ViewType.StyleConfiguration.Icon.self), - .init(ViewType.StyleConfiguration.CurrentValueLabel.self), - .init(ViewType.TabView.self), .init(ViewType.Text.self), - .init(ViewType.TextEditor.self), .init(ViewType.TextField.self), - .init(ViewType.TimelineView.self), - .init(ViewType.Toggle.self), .init(ViewType.TouchBar.self), - .init(ViewType.TupleView.self), .init(ViewType.Toolbar.self), - .init(ViewType.Toolbar.Item.self, genericTypeName: nil), - .init(ViewType.Toolbar.ItemGroup.self, genericTypeName: nil), - .init(ViewType.VideoPlayer.self), - .init(ViewType.ViewModifierContent.self), - .init(ViewType.VSplitView.self), .init(ViewType.VStack.self), - .init(ViewType.ZStack.self) + let knownViewTypes: [KnownViewType.Type] = [ + ViewType.ActionSheet.self, + ViewType.ActionSheet.self, + ViewType.Alert.self, + ViewType.AlertButton.self, + ViewType.AngularGradient.self, + ViewType.AnyView.self, + ViewType.AsyncImage.self, + ViewType.Button.self, + ViewType.Canvas.self, + ViewType.Color.self, + ViewType.ColorPicker.self, + ViewType.ConfirmationDialog.self, + ViewType.ControlGroup.self, + ViewType.DatePicker.self, + ViewType.DisclosureGroup.self, + ViewType.Divider.self, + ViewType.EditButton.self, + ViewType.EmptyView.self, + ViewType.EllipticalGradient.self, + ViewType.ForEach.self, + ViewType.Form.self, + ViewType.GeometryReader.self, + ViewType.Group.self, + ViewType.GroupBox.self, + ViewType.HSplitView.self, + ViewType.HStack.self, + ViewType.Image.self, + ViewType.Label.self, + ViewType.LazyHGrid.self, + ViewType.LazyHStack.self, + ViewType.LazyVGrid.self, + ViewType.LazyVStack.self, + ViewType.LinearGradient.self, + ViewType.Link.self, + ViewType.List.self, + ViewType.LocationButton.self, + ViewType.Map.self, + ViewType.Menu.self, + ViewType.MenuButton.self, + ViewType.NavigationLink.self, + ViewType.NavigationView.self, + ViewType.NavigationSplitView.self, + ViewType.NavigationStack.self, + ViewType.OutlineGroup.self, + ViewType.PasteButton.self, + ViewType.Picker.self, + ViewType.Popover.self, + ViewType.ProgressView.self, + ViewType.RadialGradient.self, + ViewType.SafeAreaInset.self, + ViewType.ScrollView.self, + ViewType.ScrollViewReader.self, + ViewType.Section.self, + ViewType.SecureField.self, + ViewType.SignInWithAppleButton.self, + ViewType.Sheet.self, + ViewType.Slider.self, + ViewType.Spacer.self, + ViewType.Stepper.self, + ViewType.StyleConfiguration.Label.self, + ViewType.StyleConfiguration.Content.self, + ViewType.StyleConfiguration.Title.self, + ViewType.StyleConfiguration.Icon.self, + ViewType.StyleConfiguration.CurrentValueLabel.self, + ViewType.TabView.self, + ViewType.Text.self, + ViewType.TextEditor.self, + ViewType.TextField.self, + ViewType.TimelineView.self, + ViewType.Toggle.self, + ViewType.TouchBar.self, + ViewType.TupleView.self, + ViewType.Toolbar.self, + ViewType.Toolbar.Item.self, + ViewType.Toolbar.ItemGroup.self, + ViewType.VideoPlayer.self, + ViewType.ViewModifierContent.self, + ViewType.VSplitView.self, + ViewType.VStack.self, + ViewType.ZStack.self, ] - + let identities = knownViewTypes.map { $0.viewSearchIdentity() } var index = [String: [ViewIdentity]](minimumCapacity: 26) // alphabet identities.forEach { identity in let names = identity.viewType.namespacedPrefixes @@ -82,7 +111,7 @@ internal extension ViewSearch { return index[letter]?.first(where: { $0.viewType == viewType }) } if content.isShape { - return .init(ViewType.Shape.self) + return ViewType.Shape.viewSearchIdentity() } let shortName = Inspector.typeName(value: content.view, generics: .remove) let fullName = Inspector.typeName(value: content.view, namespaced: true, generics: .remove) @@ -98,9 +127,11 @@ internal extension ViewSearch { value: content.view, generics: .customViewPlaceholder) switch content.view { case _ as any View: - return .init(ViewType.View.self, genericTypeName: name) + return ViewType.View + .viewSearchIdentity(genericTypeName: name) case _ as any ViewModifier: - return .init(ViewType.ViewModifier.self, genericTypeName: name) + return ViewType.ViewModifier + .viewSearchIdentity(genericTypeName: name) case _ as any Gesture: break default: @@ -155,10 +186,9 @@ internal extension ViewSearch { return { try self.children($0) + self.supplementary($0) + self.modifiers($0) } } - private init(_ type: T.Type, - genericTypeName: String?, - children: @escaping ChildrenBuilder = { _ in .empty }, - supplementary: @escaping SupplementaryBuilder = { _ in .empty } + fileprivate init(type: T.Type, genericTypeName: String?, + children: @escaping ChildrenBuilder, + supplementary: @escaping SupplementaryBuilder ) where T: KnownViewType { viewType = type let callWithIndex: (Int?) -> String = { index in @@ -185,55 +215,78 @@ internal extension ViewSearch { return parent.content.modifierDescendants(parent: parent) } } - - init(_ type: T.Type) where T: KnownViewType, T: SingleViewContent { - self.init(type, genericTypeName: nil, children: { parent in - try T.child(parent.content).descendants(parent) - }) - } - - init(_ type: T.Type) where T: KnownViewType, T: SingleViewContent, T: SupplementaryChildren { - self.init(type, genericTypeName: nil, children: { parent in - try T.child(parent.content).descendants(parent) - }, supplementary: { parent in - try T.supplementaryChildren(parent) - }) - } - - init(_ type: T.Type) where T: KnownViewType, T: MultipleViewContent { - self.init(type, genericTypeName: nil, children: { parent in - try T.children(parent.content).descendants(parent, indexed: true) - }) - } - - init(_ type: T.Type) - where T: KnownViewType, T: MultipleViewContent, T: SupplementaryChildren { - self.init(type, genericTypeName: nil, children: { parent in - try T.children(parent.content).descendants(parent, indexed: true) - }, supplementary: { parent in - try T.supplementaryChildren(parent) - }) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +private extension KnownViewType { + static func viewSearchIdentity(genericTypeName: String? = nil) -> ViewSearch.ViewIdentity { + return ViewSearch.ViewIdentity( + type: self, + genericTypeName: genericTypeName ?? genericViewTypeForViewSearch, + children: childViewsBuilder(), + supplementary: supplementaryViewsBuilder()) + } +} + +// MARK: - KnownViewType and extensions + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +internal protocol KnownViewType: BaseViewType { + static func childViewsBuilder() -> ViewSearch.ViewIdentity.ChildrenBuilder + static func supplementaryViewsBuilder() -> ViewSearch.ViewIdentity.SupplementaryBuilder + static var genericViewTypeForViewSearch: String? { get } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension KnownViewType { + static func childViewsBuilder() -> ViewSearch.ViewIdentity.ChildrenBuilder { + return { _ in .empty } + } + static func supplementaryViewsBuilder() -> ViewSearch.ViewIdentity.SupplementaryBuilder { + return { _ in .empty } + } + static var genericViewTypeForViewSearch: String? { nil } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension KnownViewType where Self: SingleViewContent { + static func childViewsBuilder() -> ViewSearch.ViewIdentity.ChildrenBuilder { + return { parent in + try child(parent.content).descendants(parent) } - - init(_ type: T.Type, genericTypeName: String? = nil) - where T: KnownViewType, T: SingleViewContent, T: MultipleViewContent { - self.init(type, genericTypeName: genericTypeName, children: { parent in - try T.children(parent.content).descendants(parent, indexed: true) - }) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension KnownViewType where Self: MultipleViewContent { + static func childViewsBuilder() -> ViewSearch.ViewIdentity.ChildrenBuilder { + return { parent in + try children(parent.content).descendants(parent, indexed: true) } - - init(_ type: T.Type) where T: KnownViewType { - self.init(type, genericTypeName: nil, children: { _ in .empty }) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension KnownViewType where Self: SingleViewContent & MultipleViewContent { + static func childViewsBuilder() -> ViewSearch.ViewIdentity.ChildrenBuilder { + return { parent in + try children(parent.content).descendants(parent, indexed: true) } - - init(_ type: T.Type) where T: KnownViewType, T: SupplementaryChildren { - self.init(type, genericTypeName: nil, supplementary: { parent in - try T.supplementaryChildren(parent) - }) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension KnownViewType where Self: SupplementaryChildren { + static func supplementaryViewsBuilder() -> ViewSearch.ViewIdentity.SupplementaryBuilder { + return { parent in + try supplementaryChildren(parent) } } } +// MARK: - Descendants + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) private extension LazyGroup where T == Content { func descendants(_ parent: UnwrappedView, indexed: Bool) -> LazyGroup { diff --git a/Tests/ViewInspectorTests/InspectorTests.swift b/Tests/ViewInspectorTests/InspectorTests.swift index 0738fc79..0a2fd384 100644 --- a/Tests/ViewInspectorTests/InspectorTests.swift +++ b/Tests/ViewInspectorTests/InspectorTests.swift @@ -39,6 +39,15 @@ final class InspectorTests: XCTestCase { "Type mismatch: String is not Int") } + func testUnsafeMemoryBindError() throws { + XCTAssertThrows( + try Inspector.unsafeMemoryRebind(value: Int(8), type: Bool.self), + """ + Unable to rebind value of type Swift.Int to Swift.Bool. This is \ + an internal library error, please open a ticket with these details. + """) + } + func testTypeNameValue() { let name1 = Inspector.typeName(value: Struct3()) XCTAssertEqual(name1, "Struct3") diff --git a/Tests/ViewInspectorTests/SwiftUI/CustomViewBuilderTests.swift b/Tests/ViewInspectorTests/SwiftUI/CustomViewBuilderTests.swift index 128b94e2..78041269 100644 --- a/Tests/ViewInspectorTests/SwiftUI/CustomViewBuilderTests.swift +++ b/Tests/ViewInspectorTests/SwiftUI/CustomViewBuilderTests.swift @@ -88,6 +88,24 @@ final class CustomViewBuilderTests: XCTestCase { XCTAssertEqual(path1, "hStack().view(TestViewBuilderView.self, 0).text(0)") XCTAssertEqual(path2, "hStack().view(TestViewBuilderView.self, 0).text(0)") } + + func testLocalViewBuilder() throws { + struct ViewWrapper: View { + @ViewBuilder var view: () -> V + init(@ViewBuilder view: @escaping () -> V) { + self.view = view + } + var body: some View { + view() + } + } + let view = ViewWrapper { ViewWrapper { Text("test") } } + let sut = try view.inspect() + XCTAssertNoThrow(try sut.find(ViewWrapper>.self)) + XCTAssertNoThrow(try sut.find(ViewWrapper.self)) + XCTAssertEqual(try sut.find(ViewType.Text.self).pathToRoot, + "view(ViewWrapper.self).view(ViewWrapper.self).text()") + } } // MARK: - Test Views diff --git a/Tests/ViewInspectorTests/SwiftUI/CustomViewModifierTests.swift b/Tests/ViewInspectorTests/SwiftUI/CustomViewModifierTests.swift index 3388c5e7..a248ea98 100644 --- a/Tests/ViewInspectorTests/SwiftUI/CustomViewModifierTests.swift +++ b/Tests/ViewInspectorTests/SwiftUI/CustomViewModifierTests.swift @@ -18,6 +18,16 @@ final class ModifiedContentTests: XCTestCase { .padding().padding() let sut = try view.inspect().text() XCTAssertEqual(sut.content.medium.viewModifiers.count, 5) + XCTAssertNoThrow(try sut.callOnAppear()) + } + + func testEnvAccumulatesModifiers() throws { + let view = Text("Test") + .padding().modifier(TestEnvironmentalModifier()) + .padding().padding() + let sut = try view.inspect().text() + XCTAssertEqual(sut.content.medium.viewModifiers.count, 5) + XCTAssertNoThrow(try sut.callOnAppear()) } func testExtractionFromSingleViewContainer() throws { @@ -42,6 +52,15 @@ final class ModifiedContentTests: XCTestCase { "EmptyView does not have 'TestModifier' modifier at index 1") } + func testEmptyModifierUnwrapping() throws { + let view = Text("Test") + .modifier(EmptyModifier.identity) + .padding() + let sut = try view.inspect().text() + XCTAssertEqual(sut.content.medium.viewModifiers.count, 2) + XCTAssertNoThrow(_ = try sut.modifier(EmptyModifier.self)) + } + func testSingleModifierInspection() throws { let view = EmptyView().modifier(TestModifier()) let sut = try view.inspect().emptyView().modifier(TestModifier.self) @@ -222,3 +241,11 @@ private extension TestModifier4 { } } } + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +private struct TestEnvironmentalModifier: EnvironmentalModifier { + + func resolve(in environment: EnvironmentValues) -> some ViewModifier { + return TestModifier() + } +} diff --git a/Tests/ViewInspectorTests/SwiftUI/NavigationLinkTests.swift b/Tests/ViewInspectorTests/SwiftUI/NavigationLinkTests.swift index 17564a38..6b6ee353 100644 --- a/Tests/ViewInspectorTests/SwiftUI/NavigationLinkTests.swift +++ b/Tests/ViewInspectorTests/SwiftUI/NavigationLinkTests.swift @@ -6,10 +6,12 @@ import SwiftUI final class NavigationLinkTests: XCTestCase { func testEnclosedView() throws { - let view = NavigationLink( - destination: TestView(parameter: "Screen 1")) { Text("GoTo 1") } - let nextView = try view.inspect().navigationLink().view(TestView.self).actualView() - XCTAssertEqual(nextView.parameter, "Screen 1") + let view = NavigationLink(destination: { + Text("1"); Text("2") + }, label: { Text("label") }) + let sut = try view.inspect().navigationLink() + XCTAssertEqual(try sut.text(0).string(), "1") + XCTAssertEqual(try sut.text(1).string(), "2") } func testLabelView() throws { @@ -47,7 +49,7 @@ final class NavigationLinkTests: XCTestCase { } @available(watchOS 7.0, *) - func testSearch() throws { + func testSearchNoBindings() throws { let view = AnyView(NavigationView { NavigationLink( destination: TestView(parameter: "Screen 1")) { Text("GoTo 1") } @@ -67,34 +69,53 @@ final class NavigationLinkTests: XCTestCase { } @available(watchOS 7.0, *) - func testNavigationWithoutBindingAndState() throws { + func testSearchWithBindings() throws { + let selection = Binding(wrappedValue: nil) + let sut = try TestViewBinding(selection: selection).inspect() + let notFoundError = "Search did not find a match" + XCTAssertThrows(try sut.find(text: "Screen 1"), notFoundError) + XCTAssertThrows(try sut.find(text: "Screen 2"), notFoundError) + try sut.navigationView().navigationLink(0).activate() + XCTAssertNoThrow(try sut.find(text: "Screen 1")) + XCTAssertThrows(try sut.find(text: "Screen 2"), notFoundError) + try sut.navigationView().navigationLink(1).activate() + XCTAssertThrows(try sut.find(text: "Screen 1"), notFoundError) + XCTAssertNoThrow(try sut.find(text: "Screen 2")) + XCTAssertThrows(try sut.navigationView().navigationLink(0).view(TestView.self), + "View for NavigationLink's destination is absent") + XCTAssertNoThrow(try sut.navigationView().navigationLink(1).view(TestView.self)) + } + + @available(watchOS 7.0, *) + func testNavigationWithoutBindingParameter() throws { guard #available(iOS 13.1, macOS 10.16, tvOS 13.1, *) else { throw XCTSkip() } let view = NavigationView { NavigationLink( destination: TestView(parameter: "Screen 1")) { Text("GoTo 1") } } - let isActive = try view.inspect().navigationView().navigationLink(0).isActive() - XCTAssertFalse(isActive) - XCTAssertThrows( - try view.inspect().navigationView().navigationLink(0).activate(), - "Enable programmatic navigation by using `NavigationLink(destination:, tag:, selection:)`") + let sut = try view.inspect().navigationView().navigationLink(0) + let errorMessage = """ + Please use `NavigationLink(destination:, tag:, selection:)` \ + if you need to access the state value for reading or writing. + """ + XCTAssertThrows(try sut.isActive(), errorMessage) + XCTAssertThrows(try sut.activate(), errorMessage) + XCTAssertThrows(try sut.deactivate(), errorMessage) } @available(watchOS 7.0, *) func testNavigationWithStateActivation() throws { let view = TestViewState() XCTAssertNil(view.state.selection) - let isActive1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActive2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertFalse(isActive1) - XCTAssertFalse(isActive2) - try view.inspect().navigationView().navigationLink(0).activate() + let sut0 = try view.inspect().navigationView().navigationLink(0) + let sut1 = try view.inspect().navigationView().navigationLink(1) + XCTAssertFalse(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) + try sut0.activate() XCTAssertEqual(view.state.selection, view.tag1) - let isActiveAfter1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActiveAfter2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertTrue(isActiveAfter1) - XCTAssertFalse(isActiveAfter2) + XCTAssertTrue(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) } @available(watchOS 7.0, *) @@ -102,32 +123,28 @@ final class NavigationLinkTests: XCTestCase { let selection = Binding(wrappedValue: nil) let view = TestViewBinding(selection: selection) XCTAssertNil(view.$selection.wrappedValue) - let isActive1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActive2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertFalse(isActive1) - XCTAssertFalse(isActive2) - try view.inspect().navigationView().navigationLink(0).activate() + let sut0 = try view.inspect().navigationView().navigationLink(0) + let sut1 = try view.inspect().navigationView().navigationLink(1) + XCTAssertFalse(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) + try sut0.activate() XCTAssertEqual(view.$selection.wrappedValue, view.tag1) - let isActiveAfter1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActiveAfter2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertTrue(isActiveAfter1) - XCTAssertFalse(isActiveAfter2) + XCTAssertTrue(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) } @available(watchOS 7.0, *) func testNavigationWithStateDeactivation() throws { let view = TestViewState() view.state.selection = view.tag2 - let isActive1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActive2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertFalse(isActive1) - XCTAssertTrue(isActive2) - try view.inspect().navigationView().navigationLink(1).deactivate() + let sut0 = try view.inspect().navigationView().navigationLink(0) + let sut1 = try view.inspect().navigationView().navigationLink(1) + XCTAssertFalse(try sut0.isActive()) + XCTAssertTrue(try sut1.isActive()) + try sut1.deactivate() XCTAssertNil(view.state.selection) - let isActiveAfter1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActiveAfter2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertFalse(isActiveAfter1) - XCTAssertFalse(isActiveAfter2) + XCTAssertFalse(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) } @available(watchOS 7.0, *) @@ -135,43 +152,56 @@ final class NavigationLinkTests: XCTestCase { let selection = Binding(wrappedValue: nil) let view = TestViewBinding(selection: selection) view.selection = view.tag2 - let isActive1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActive2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertFalse(isActive1) - XCTAssertTrue(isActive2) - try view.inspect().navigationView().navigationLink(1).deactivate() + let sut0 = try view.inspect().navigationView().navigationLink(0) + let sut1 = try view.inspect().navigationView().navigationLink(1) + XCTAssertFalse(try sut0.isActive()) + XCTAssertTrue(try sut1.isActive()) + try sut1.deactivate() XCTAssertNil(view.selection) - let isActiveAfter1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActiveAfter2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertFalse(isActiveAfter1) - XCTAssertFalse(isActiveAfter2) + XCTAssertFalse(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) } @available(watchOS 7.0, *) func testNavigationWithStateReactivation() throws { let view = TestViewState() - try view.inspect().navigationView().navigationLink(1).activate() + let sut0 = try view.inspect().navigationView().navigationLink(0) + let sut1 = try view.inspect().navigationView().navigationLink(1) + try sut1.activate() XCTAssertEqual(view.state.selection, view.tag2) - try view.inspect().navigationView().navigationLink(0).activate() + try sut0.activate() XCTAssertEqual(view.state.selection, view.tag1) - let isActiveAfter1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActiveAfter2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertTrue(isActiveAfter1) - XCTAssertFalse(isActiveAfter2) + XCTAssertTrue(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) } @available(watchOS 7.0, *) func testNavigationWithBindingReactivation() throws { let selection = Binding(wrappedValue: nil) let view = TestViewBinding(selection: selection) - try view.inspect().navigationView().navigationLink(1).activate() + let sut0 = try view.inspect().navigationView().navigationLink(0) + let sut1 = try view.inspect().navigationView().navigationLink(1) + try sut1.activate() XCTAssertEqual(view.selection, view.tag2) - try view.inspect().navigationView().navigationLink(0).activate() + try sut0.activate() XCTAssertEqual(view.selection, view.tag1) - let isActiveAfter1 = try view.inspect().navigationView().navigationLink(0).isActive() - let isActiveAfter2 = try view.inspect().navigationView().navigationLink(1).isActive() - XCTAssertTrue(isActiveAfter1) - XCTAssertFalse(isActiveAfter2) + XCTAssertTrue(try sut0.isActive()) + XCTAssertFalse(try sut1.isActive()) + } + + @available(watchOS 7.0, *) + func testRecursiveNavigationLinks() throws { + let sut = try TestRecursiveLinksView().inspect() + XCTAssertThrows(try sut.find(ViewType.Text.self, traversal: .breadthFirst, where: { _ in false }), + "Search did not find a match") + XCTAssertThrows(try sut.find(ViewType.Text.self, traversal: .depthFirst, where: { _ in false }), + "Search did not find a match") + XCTAssertEqual( + try sut.find(text: "B to A").pathToRoot, + """ + view(TestRecursiveLinksView.self).navigationView().view(ViewAtoB.self)\ + .navigationLink().view(ViewBtoA.self).navigationLink().labelView().text() + """) } } @@ -225,3 +255,23 @@ extension TestViewState { @Published var selection: String? } } + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 7.0, *) +private struct TestRecursiveLinksView: View { + + struct ViewAtoB: View { + var body: some View { + NavigationLink(destination: ViewBtoA()) { Text("A to B") } + } + } + + struct ViewBtoA: View { + var body: some View { + NavigationLink(destination: ViewAtoB()) { Text("B to A") } + } + } + + var body: some View { + NavigationView { ViewAtoB() } + } +} diff --git a/Tests/ViewInspectorTests/SwiftUI/NavigationSplitViewTests.swift b/Tests/ViewInspectorTests/SwiftUI/NavigationSplitViewTests.swift new file mode 100644 index 00000000..dc9b88e4 --- /dev/null +++ b/Tests/ViewInspectorTests/SwiftUI/NavigationSplitViewTests.swift @@ -0,0 +1,99 @@ +import XCTest +import SwiftUI +@testable import ViewInspector + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 7.0, *) +final class NavigationSplitViewTests: XCTestCase { + + func testSingleEnclosedView() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = NavigationSplitView(sidebar: { Text("1") }, + content: { Text("2") }, + detail: { Text("3") }) + let sut = try view.inspect().navigationSplitView() + let content = try sut.text().string() + let sidebar = try sut.sidebarView().text().string() + let detail = try sut.detailView().text().string() + XCTAssertEqual(sidebar, "1") + XCTAssertEqual(content, "2") + XCTAssertEqual(detail, "3") + } + + func testMultipleEnclosedViews() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = NavigationSplitView(sidebar: { Text("1"); Text("11") }, + content: { Text("2"); Text("22") }, + detail: { Text("3"); Text("33") }) + let sut = try view.inspect().navigationSplitView() + let content1 = try sut.text().string() + let content2 = try sut.text(1).string() + let sidebar1 = try sut.sidebarView().text(0).string() + let sidebar2 = try sut.sidebarView().text(1).string() + let detail1 = try sut.detailView().text(0).string() + let detail2 = try sut.detailView().text(1).string() + XCTAssertEqual(sidebar1, "1") + XCTAssertEqual(sidebar2, "11") + XCTAssertEqual(content1, "2") + XCTAssertEqual(content2, "22") + XCTAssertEqual(detail1, "3") + XCTAssertEqual(detail2, "33") + } + + func testResetsModifiers() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = NavigationSplitView( + sidebar: { Text("1") }, content: { Text("2") }, detail: { Text("3") }) + .padding() + let sut = try view.inspect().navigationSplitView() + let content = try sut.text() + let sidebar = try sut.sidebarView().text() + let detail = try sut.detailView().text() + XCTAssertEqual(content.content.medium.viewModifiers.count, 0) + XCTAssertEqual(sidebar.content.medium.viewModifiers.count, 0) + XCTAssertEqual(detail.content.medium.viewModifiers.count, 0) + } + + func testExtractionFromSingleViewContainer() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = AnyView(NavigationSplitView(sidebar: { EmptyView() }, detail: { EmptyView() })) + XCTAssertNoThrow(try view.inspect().anyView().navigationSplitView()) + } + + func testExtractionFromMultipleViewContainer() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = HStack { + NavigationSplitView(sidebar: { EmptyView() }, detail: { EmptyView() }) + NavigationSplitView(sidebar: { EmptyView() }, detail: { EmptyView() }) + } + XCTAssertNoThrow(try view.inspect().hStack().navigationSplitView(0)) + XCTAssertNoThrow(try view.inspect().hStack().navigationSplitView(1)) + } + + func testSearch() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = AnyView(NavigationSplitView( + sidebar: { Text("1") }, + content: { Text("2"); Text("22") }, + detail: { Text("3"); Text("33") }) + ) + let sut = try view.inspect() + XCTAssertEqual(try sut.find(ViewType.NavigationSplitView.self).pathToRoot, + "anyView().navigationSplitView()") + XCTAssertEqual(try sut.find(text: "1").pathToRoot, + "anyView().navigationSplitView().sidebarView().text()") + XCTAssertEqual(try sut.find(text: "2").pathToRoot, + "anyView().navigationSplitView().text(0)") + XCTAssertEqual(try sut.find(text: "22").pathToRoot, + "anyView().navigationSplitView().text(1)") + XCTAssertEqual(try sut.find(text: "3").pathToRoot, + "anyView().navigationSplitView().detailView().text(0)") + XCTAssertEqual(try sut.find(text: "33").pathToRoot, + "anyView().navigationSplitView().detailView().text(1)") + } +} diff --git a/Tests/ViewInspectorTests/SwiftUI/NavigationStackTests.swift b/Tests/ViewInspectorTests/SwiftUI/NavigationStackTests.swift new file mode 100644 index 00000000..bb569ae2 --- /dev/null +++ b/Tests/ViewInspectorTests/SwiftUI/NavigationStackTests.swift @@ -0,0 +1,67 @@ +import XCTest +import SwiftUI +@testable import ViewInspector + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 7.0, *) +final class NavigationStackTests: XCTestCase { + + func testSingleEnclosedView() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let sampleView = Text("Test") + let view = NavigationStack { sampleView } + let sut = try view.inspect().navigationStack().text().content.view as? Text + XCTAssertEqual(sut, sampleView) + } + + func testMultipleEnclosedViews() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let sampleView1 = Text("Test") + let sampleView2 = Text("Abc") + let sampleView3 = Text("XYZ") + let view = NavigationStack { sampleView1; sampleView2; sampleView3 } + let view1 = try view.inspect().navigationStack().text().content.view as? Text + let view2 = try view.inspect().navigationStack().text(1).content.view as? Text + let view3 = try view.inspect().navigationStack().text(2).content.view as? Text + XCTAssertEqual(view1, sampleView1) + XCTAssertEqual(view2, sampleView2) + XCTAssertEqual(view3, sampleView3) + } + + func testResetsModifiers() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = NavigationStack { Text("Test") }.padding() + let sut = try view.inspect().navigationStack().text() + XCTAssertEqual(sut.content.medium.viewModifiers.count, 0) + } + + func testExtractionFromSingleViewContainer() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = AnyView(NavigationStack { Text("") }) + XCTAssertNoThrow(try view.inspect().anyView().navigationStack()) + } + + func testExtractionFromMultipleViewContainer() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = HStack { + NavigationStack { Text("") } + NavigationStack { Text("") } + } + XCTAssertNoThrow(try view.inspect().hStack().navigationStack(0)) + XCTAssertNoThrow(try view.inspect().hStack().navigationStack(1)) + } + + func testSearch() throws { + guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + else { throw XCTSkip() } + let view = AnyView(NavigationStack { Text("abc") }) + XCTAssertEqual(try view.inspect().find(ViewType.NavigationStack.self).pathToRoot, + "anyView().navigationStack()") + XCTAssertEqual(try view.inspect().find(text: "abc").pathToRoot, + "anyView().navigationStack().text()") + } +} diff --git a/Tests/ViewInspectorTests/SwiftUI/NavigationViewTests.swift b/Tests/ViewInspectorTests/SwiftUI/NavigationViewTests.swift index 0cbcb36c..b03b501d 100644 --- a/Tests/ViewInspectorTests/SwiftUI/NavigationViewTests.swift +++ b/Tests/ViewInspectorTests/SwiftUI/NavigationViewTests.swift @@ -8,7 +8,7 @@ final class NavigationViewTests: XCTestCase { func testSingleEnclosedView() throws { let sampleView = Text("Test") let view = NavigationView { sampleView } - let sut = try view.inspect().navigationView().text(0).content.view as? Text + let sut = try view.inspect().navigationView().text().content.view as? Text XCTAssertEqual(sut, sampleView) } @@ -16,10 +16,10 @@ final class NavigationViewTests: XCTestCase { let sampleView1 = Text("Test") let sampleView2 = Text("Abc") let sampleView3 = Text("XYZ") - let view = Group { sampleView1; sampleView2; sampleView3 } - let view1 = try view.inspect().group().text(0).content.view as? Text - let view2 = try view.inspect().group().text(1).content.view as? Text - let view3 = try view.inspect().group().text(2).content.view as? Text + let view = NavigationView { sampleView1; sampleView2; sampleView3 } + let view1 = try view.inspect().navigationView().text().content.view as? Text + let view2 = try view.inspect().navigationView().text(1).content.view as? Text + let view3 = try view.inspect().navigationView().text(2).content.view as? Text XCTAssertEqual(view1, sampleView1) XCTAssertEqual(view2, sampleView2) XCTAssertEqual(view3, sampleView3) @@ -27,7 +27,7 @@ final class NavigationViewTests: XCTestCase { func testResetsModifiers() throws { let view = NavigationView { Text("Test") }.padding() - let sut = try view.inspect().navigationView().text(0) + let sut = try view.inspect().navigationView().text() XCTAssertEqual(sut.content.medium.viewModifiers.count, 0) } @@ -50,6 +50,6 @@ final class NavigationViewTests: XCTestCase { XCTAssertEqual(try view.inspect().find(ViewType.NavigationView.self).pathToRoot, "anyView().navigationView()") XCTAssertEqual(try view.inspect().find(text: "abc").pathToRoot, - "anyView().navigationView().text(0)") + "anyView().navigationView().text()") } } diff --git a/Tests/ViewInspectorTests/SwiftUI/TabViewTests.swift b/Tests/ViewInspectorTests/SwiftUI/TabViewTests.swift index 3b98360b..c0a2b5d1 100644 --- a/Tests/ViewInspectorTests/SwiftUI/TabViewTests.swift +++ b/Tests/ViewInspectorTests/SwiftUI/TabViewTests.swift @@ -53,6 +53,47 @@ final class TabViewTests: XCTestCase { XCTAssertEqual(try view.inspect().find(text: "xyz").pathToRoot, "anyView().tabView().text(0).tabItem().text()") } + + func testTabSelection() throws { + let sut = TestTabSelectionView() + let viewNotFound = "Search did not find a match" + let exp = sut.inspection.inspect { view in + XCTAssertEqual(try view.actualView().selectedTab, 1) + XCTAssertNoThrow(try view.find(text: "tab_1")) + XCTAssertThrows(try view.tabView(1).text(2), + "View for tab with tag 3 is absent") + XCTAssertThrows(try view.find(text: "tab_2"), viewNotFound) + XCTAssertThrows(try view.find(text: "tab_3"), viewNotFound) + try view.find(button: "select_tab_3").tap() + XCTAssertEqual(try view.actualView().selectedTab, 3) + } + + let exp2 = sut.inspection.inspect(after: 0.1) { view in + XCTAssertEqual(try view.actualView().selectedTab, 3) + XCTAssertThrows(try view.find(text: "tab_1"), viewNotFound) + XCTAssertThrows(try view.find(text: "tab_2"), viewNotFound) + XCTAssertNoThrow(try view.find(text: "tab_3")) + } + ViewHosting.host(view: sut) + wait(for: [exp, exp2], timeout: 2) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +private struct TestTabSelectionView: View { + + @State var selectedTab = 1 + let inspection = Inspection() + + public var body: some View { + Button("select_tab_3", action: { selectedTab = 3 }) + TabView(selection: $selectedTab) { + Text("tab_1").tag(1) + Text("tab_2").tag(2) + Text("tab_3").tag(3) + } + .onReceive(inspection.notice) { self.inspection.visit(self, $0) } + } } // MARK: - View Modifiers diff --git a/Tests/ViewInspectorTests/SwiftUI/TimelineViewTests.swift b/Tests/ViewInspectorTests/SwiftUI/TimelineViewTests.swift index c6e750d9..e51af656 100644 --- a/Tests/ViewInspectorTests/SwiftUI/TimelineViewTests.swift +++ b/Tests/ViewInspectorTests/SwiftUI/TimelineViewTests.swift @@ -6,6 +6,20 @@ import SwiftUI final class TimelineViewTests: XCTestCase { #if !os(watchOS) + + func testTimelineViewContext() throws { + guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + else { throw XCTSkip() } + typealias SUT = ViewType.TimelineView.Context + typealias SpecificTimelineView = TimelineView + let date = Date() + let value = SUT(date: date, cadence: .minutes) + let adapted = try SpecificTimelineView.adapt(context: value) + let rebound = try Inspector.unsafeMemoryRebind(value: adapted, type: SpecificTimelineView.Context.self) + XCTAssertEqual(rebound.date, date) + XCTAssertEqual(rebound.cadence, .minutes) + } + func testEnclosedView() throws { guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) else { throw XCTSkip() } diff --git a/ViewInspector.podspec b/ViewInspector.podspec index fd12e628..5201c5b8 100644 --- a/ViewInspector.podspec +++ b/ViewInspector.podspec @@ -2,7 +2,7 @@ Pod::Spec.new do |s| s.name = "ViewInspector" - s.version = "0.9.3" + s.version = "0.9.4" s.summary = "ViewInspector is a library for unit testing SwiftUI views." s.homepage = "https://github.com/nalexn/ViewInspector" s.license = { :type => "MIT", :file => "LICENSE" } diff --git a/ViewInspector.xcodeproj/project.pbxproj b/ViewInspector.xcodeproj/project.pbxproj index 2c939f53..34a6704c 100644 --- a/ViewInspector.xcodeproj/project.pbxproj +++ b/ViewInspector.xcodeproj/project.pbxproj @@ -56,6 +56,10 @@ 5250764D265185A800ADE4E7 /* ActionSheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5250764C265185A800ADE4E7 /* ActionSheetTests.swift */; }; 52510DCE25E1486200A84CB8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52510DC825E1485300A84CB8 /* SwiftUI.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 52510DD325E1487200A84CB8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52510DC825E1485300A84CB8 /* SwiftUI.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 529633862969672A00EC6C81 /* NavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529633852969672A00EC6C81 /* NavigationStack.swift */; }; + 52963388296967ED00EC6C81 /* NavigationStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52963387296967ED00EC6C81 /* NavigationStackTests.swift */; }; + 5296338A29697C7D00EC6C81 /* NavigationSplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5296338929697C7D00EC6C81 /* NavigationSplitView.swift */; }; + 5296338C29697EB400EC6C81 /* NavigationSplitViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5296338B29697EB400EC6C81 /* NavigationSplitViewTests.swift */; }; 5298500125FF5B7A00C717AB /* Test.strings in Resources */ = {isa = PBXBuildFile; fileRef = F6FEBEF4256AE732006C28F5 /* Test.strings */; }; 52A3B51A26591F59001FE17E /* Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A3B51926591F59001FE17E /* Sheet.swift */; }; 52A3B51C26591F79001FE17E /* SheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A3B51B26591F79001FE17E /* SheetTests.swift */; }; @@ -325,6 +329,10 @@ 5250764A2651800800ADE4E7 /* ActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionSheet.swift; sourceTree = ""; }; 5250764C265185A800ADE4E7 /* ActionSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionSheetTests.swift; sourceTree = ""; }; 52510DC825E1485300A84CB8 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 529633852969672A00EC6C81 /* NavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStack.swift; sourceTree = ""; }; + 52963387296967ED00EC6C81 /* NavigationStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackTests.swift; sourceTree = ""; }; + 5296338929697C7D00EC6C81 /* NavigationSplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitView.swift; sourceTree = ""; }; + 5296338B29697EB400EC6C81 /* NavigationSplitViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitViewTests.swift; sourceTree = ""; }; 52A3B51926591F59001FE17E /* Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sheet.swift; sourceTree = ""; }; 52A3B51B26591F79001FE17E /* SheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetTests.swift; sourceTree = ""; }; 52A4A7632621F4AE0063E00B /* Overlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overlay.swift; sourceTree = ""; }; @@ -686,6 +694,8 @@ F6C15A70254B68C0000240F1 /* Menu.swift */, F6684BEF23AA504500DECCB3 /* MenuButton.swift */, F6D9339E2385B46A00358E0E /* NavigationLink.swift */, + 5296338929697C7D00EC6C81 /* NavigationSplitView.swift */, + 529633852969672A00EC6C81 /* NavigationStack.swift */, F6DFD87F238303AF0028E84D /* NavigationView.swift */, 79069A6A238E8490000F6B58 /* OptionalContent.swift */, F6C15A84254D806C000240F1 /* OutlineGroup.swift */, @@ -804,6 +814,8 @@ F6C15A6A254B669F000240F1 /* MenuTests.swift */, F6684BED23AA4CF400DECCB3 /* MenuButtonTests.swift */, F6D933A02385B48100358E0E /* NavigationLinkTests.swift */, + 5296338B29697EB400EC6C81 /* NavigationSplitViewTests.swift */, + 52963387296967ED00EC6C81 /* NavigationStackTests.swift */, F6DFD881238303C60028E84D /* NavigationViewTests.swift */, F6E4B4052386F511003E1534 /* OpaqueViewTests.swift */, F64057F2238E9F600029D9BA /* OptionalViewTests.swift */, @@ -1140,6 +1152,7 @@ F609EFBD23A40B8800B9256A /* DelayedPreferenceView.swift in Sources */, F6684BF223AA554500DECCB3 /* PasteButton.swift in Sources */, F60EEBD12382EED0007DB53A /* DatePicker.swift in Sources */, + 529633862969672A00EC6C81 /* NavigationStack.swift in Sources */, 52E24E9926E9378A00711987 /* ConfirmationDialog.swift in Sources */, F64A2C6823A3FD3A00A4853A /* TreeView.swift in Sources */, F6A35A4025B35E920068B8B2 /* LazyGroup.swift in Sources */, @@ -1148,6 +1161,7 @@ F6D9339B2385AD9100358E0E /* GeometryReader.swift in Sources */, F6D933C72385FC0100358E0E /* Stepper.swift in Sources */, F6D933A32385DBFD00358E0E /* TabView.swift in Sources */, + 5296338A29697C7D00EC6C81 /* NavigationSplitView.swift in Sources */, F6D933A72385E9E000358E0E /* EquatableView.swift in Sources */, 52F356AA267692D100695E43 /* MapAnnotation.swift in Sources */, F68108A623A5835E00B32145 /* EventsModifiers.swift in Sources */, @@ -1196,6 +1210,7 @@ CDA844F2262B86D900C56C98 /* GestureExampleTests.swift in Sources */, F6D933C92385FC1A00358E0E /* StepperTests.swift in Sources */, F60EEC012382F004007DB53A /* ButtonTests.swift in Sources */, + 52963388296967ED00EC6C81 /* NavigationStackTests.swift in Sources */, F60EEC042382F004007DB53A /* GroupTests.swift in Sources */, F6119FEA2549F5960000C54A /* LazyHGridTests.swift in Sources */, F6DFD882238303C60028E84D /* NavigationViewTests.swift in Sources */, @@ -1257,6 +1272,7 @@ F682A00C254772C3005F1B70 /* DisclosureGroupTests.swift in Sources */, CDA844BC262B7EA800C56C98 /* MagnificationGestureTests.swift in Sources */, F6C15A3F254B4835000240F1 /* LinkTests.swift in Sources */, + 5296338C29697EB400EC6C81 /* NavigationSplitViewTests.swift in Sources */, F6ECF6D823A69CB5000FC591 /* VisualEffectModifiersTests.swift in Sources */, F6DB5A0B253510CC0056FC83 /* TextInputModifiersTests.swift in Sources */, F609EFC323A4342400B9256A /* PreferenceTests.swift in Sources */, diff --git a/readiness.md b/readiness.md index dfb04006..825c6094 100644 --- a/readiness.md +++ b/readiness.md @@ -73,6 +73,8 @@ This document reflects the current status of the [ViewInspector](https://github. |:white_check_mark:| MenuStyleConfiguration.Label | | |:white_check_mark:| ModifiedContent | `contained view` | |:white_check_mark:| NavigationLink | `contained view`, `label view`, `isActive: Bool`, `activate()`, `deactivate()` | +|:white_check_mark:| NavigationSplitView | `contained view`, `sidebar view`, `detail view` | +|:white_check_mark:| NavigationStack | `contained view` | |:white_check_mark:| NavigationView | `contained view` | |:technologist:| NowPlayingView | | |:white_check_mark:| Optional | `contained view` |