Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//
// FormHierarchyTestHelpers.swift
// StripePaymentSheetTests
//
// Test utilities for verifying form hierarchies generated by form specs.
//

import Foundation
@testable @_spi(STP) import StripePaymentSheet
@testable @_spi(STP) import StripeUICore
import XCTest

// MARK: - FormHierarchyNode

/// A testable, equatable representation of an Element hierarchy.
/// Use this to verify the structure of forms generated by form specs.
struct FormHierarchyNode: Equatable, CustomStringConvertible {
let type: String
let properties: [String: String]
let children: [FormHierarchyNode]

init(type: String, properties: [String: String] = [:], children: [FormHierarchyNode] = []) {
self.type = type
self.properties = properties
self.children = children
}

var description: String {
return descriptionWithIndent(0)
}

private func descriptionWithIndent(_ indent: Int) -> String {
let indentStr = String(repeating: " ", count: indent)
let propsStr = properties.isEmpty ? "" : "(" + properties.sorted(by: { $0.key < $1.key }).map { "\($0.key): \"\($0.value)\"" }.joined(separator: ", ") + ")"
var result = "\(indentStr)\(type)\(propsStr)"

if !children.isEmpty {
result += " {\n"
for child in children {
result += child.descriptionWithIndent(indent + 1) + "\n"
}
result += "\(indentStr)}"
}

return result
}
}

// MARK: - Element to Hierarchy Conversion

extension Element {
/// Converts this element and its children into a testable hierarchy representation.
/// Automatically unwraps PaymentMethodElementWrapper.
func toHierarchyNode() -> FormHierarchyNode {
// Unwrap PaymentMethodElementWrapper by checking the type name
// (We can't check the protocol directly since AnyPaymentMethodElementWrapper is private)
if let wrappedElement = unwrappedElement(from: self) {
return wrappedElement.toHierarchyNode()
}

let (typeName, props) = extractTypeAndProperties()
let childNodes: [FormHierarchyNode]

if let container = self as? ContainerElement {
childNodes = container.elements.map { $0.toHierarchyNode() }
} else {
childNodes = []
}

return FormHierarchyNode(type: typeName, properties: props, children: childNodes)
}

/// Attempts to unwrap a PaymentMethodElementWrapper to get its underlying element
private func unwrappedElement(from element: Element) -> Element? {
// Use Mirror to check if this is a PaymentMethodElementWrapper and extract the wrapped element
let mirror = Mirror(reflecting: element)
let typeName = String(describing: type(of: element))

if typeName.hasPrefix("PaymentMethodElementWrapper") {
for child in mirror.children {
if child.label == "element", let unwrapped = child.value as? Element {
return unwrapped
}
}
}
return nil
}

/// Extracts the type name and relevant properties for testing
private func extractTypeAndProperties() -> (String, [String: String]) {
switch self {
// Text fields
case let textField as TextFieldElement:
return ("TextFieldElement", ["label": textField.configuration.label])

// Dropdowns
case let dropdown as DropdownFieldElement:
var props: [String: String] = [:]
if let label = dropdown.label {
props["label"] = label
}
props["itemCount"] = String(dropdown.items.count)
return ("DropdownFieldElement", props)

// Sections
case let section as SectionElement:
var props: [String: String] = [:]
if let title = section.title {
props["title"] = title
}
return ("SectionElement", props)

// Form
case is FormElement:
return ("FormElement", [:])

// Phone number
case is PhoneNumberElement:
return ("PhoneNumberElement", [:])

// Address section
case is AddressSectionElement:
return ("AddressSectionElement", [:])

// Checkbox
case let checkbox as CheckboxElement:
return ("CheckboxElement", ["label": checkbox.label])

// Static element (for mandates, headers, etc.)
case let staticElement as StaticElement:
// Try to identify the type of view for more specific matching
let viewType = String(describing: type(of: staticElement.view))
return ("StaticElement", ["viewType": viewType])

// Subtitle element (for headers like Affirm, Klarna)
case is SubtitleElement:
return ("SubtitleElement", [:])

// SimpleMandateElement
case let mandate as SimpleMandateElement:
let mandateText = mandate.mandateTextView.attributedText?.string ?? ""
// Truncate mandate text for readability
let truncated = mandateText.count > 50 ? String(mandateText.prefix(50)) + "..." : mandateText
return ("SimpleMandateElement", ["text": truncated])

// Hidden element wrapper
case is SectionElement.HiddenElement:
return ("HiddenElement", [:])

// CardSectionElement
case is CardSectionElement:
return ("CardSectionElement", [:])

// USBankAccountPaymentMethodElement
case is USBankAccountPaymentMethodElement:
return ("USBankAccountPaymentMethodElement", [:])

// InstantDebitsPaymentMethodElement
case is InstantDebitsPaymentMethodElement:
return ("InstantDebitsPaymentMethodElement", [:])

// MultiElementRow (horizontal row of elements, e.g., MM/YY + CVC)
case is SectionElement.MultiElementRow:
return ("MultiElementRow", [:])

// DummyAddressLine (placeholder for autocomplete address line)
case is AddressSectionElement.DummyAddressLine:
return ("DummyAddressLine", [:])

// Unknown element type - this will cause test failure with a clear message
// Add a new case above when you encounter this
default:
let typeName = String(describing: type(of: self))
return ("UNHANDLED_ELEMENT_TYPE<\(typeName)>", [:])
}
}
}

// MARK: - Helper to print hierarchy (for debugging / generating expected values)

extension Element {
/// Prints the hierarchy in a format that can be copy-pasted into test code.
/// Call this when writing new tests to generate the expected hierarchy.
func printHierarchyForTests() {
print(toHierarchyNode().asSwiftCode())
}
}

extension FormHierarchyNode {
/// Returns Swift code that constructs this node, useful for generating test expectations.
func asSwiftCode(indent: Int = 0) -> String {
let indentStr = String(repeating: " ", count: indent)
let nextIndent = String(repeating: " ", count: indent + 1)

var propsCode = ""
if !properties.isEmpty {
let sortedProps = properties.sorted(by: { $0.key < $1.key })
propsCode = sortedProps.map { "\"\($0.key)\": \"\($0.value)\"" }.joined(separator: ", ")
propsCode = "properties: [\(propsCode)]"
}

if children.isEmpty {
if properties.isEmpty {
return "\(indentStr)FormHierarchyNode(type: \"\(type)\")"
} else {
return "\(indentStr)FormHierarchyNode(type: \"\(type)\", \(propsCode))"
}
} else {
var result = "\(indentStr)FormHierarchyNode(type: \"\(type)\""
if !properties.isEmpty {
result += ", \(propsCode)"
}
result += ", children: [\n"
for (index, child) in children.enumerated() {
result += child.asSwiftCode(indent: indent + 1)
if index < children.count - 1 {
result += ","
}
result += "\n"
}
result += "\(nextIndent)])"
return result
}
}
}
Loading
Loading