diff --git a/Sources/OpenSwiftUICore/Layout/Geometry/Spacing.swift b/Sources/OpenSwiftUICore/Layout/Geometry/Spacing.swift index a6979877..ca1d084e 100644 --- a/Sources/OpenSwiftUICore/Layout/Geometry/Spacing.swift +++ b/Sources/OpenSwiftUICore/Layout/Geometry/Spacing.swift @@ -1,83 +1,555 @@ // // Spacing.swift -// OpenSwiftUI +// OpenSwiftUICore // -// Audited for iOS 15.5 -// Status: TODO -// ID: 127A76D3C8081D0134153BE9AE746714 +// Audited for iOS 18.0 +// Status: Complete +// ID: 127A76D3C8081D0134153BE9AE746714 (SwiftUI) +// ID: EF1C7FCB82CB27FA7772A4944789FD3D (SwiftUICore) -import Foundation +package import Foundation -package struct Spacing { - // TODO - static let zero = Spacing(minima: [:]) - static let zeroHorizontal = Spacing(minima: [:]) - static let zeroVertical = Spacing(minima: [:]) +/// The default spacing value used throughout the framework +package let defaultSpacingValue = CoreGlue.shared.defaultSpacing - var minima: [Key: CGFloat] +// MARK: Spacing - func distanceToSuccessorView(along axis: Axis, preferring spacing: Spacing) -> CGFloat? { - if minima.count >= spacing.minima.count { - switch axis { - case .horizontal: _distance(from: .leading, to: .trailing, ofViewPreferring: spacing) - case .vertical: _distance(from: .top, to: .bottom, ofViewPreferring: spacing) +/// A structure that represents spacing between views in a layout +/// +/// Spacing is used to define the distances between views in various contexts, such as +/// within stacks, grids, and other container views. It supports both fixed distances +/// and text-aware spacing metrics. +@_spi(ForOpenSwiftUIOnly) +public struct Spacing: Equatable, Sendable { + // MARK: - Spacing.Category + + /// A type that categorizes different types of spacing + /// + /// Categories allow the spacing system to apply different rules for different + /// spacing contexts, such as text-to-text spacing, edge spacing, and baseline spacing. + package struct Category: Hashable { + /// The underlying type used to uniquely identify this category + var type: any Any.Type + + /// Creates a new spacing category from the given type + /// + /// - Parameter t: The type used to identify this category + package init(_ t: any Any.Type) { + self.type = t + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(type)) + } + + package static func == (lhs: Spacing.Category, rhs: Spacing.Category) -> Bool { + lhs.type == rhs.type + } + } + + // MARK: - Spacing.Key + + /// A key that uniquely identifies a spacing value by its category and edge + /// + /// A spacing key combines a category (optional) with an absolute edge to identify + /// a specific spacing value within a `Spacing` instance. + package struct Key: Hashable { + /// The optional category for this spacing key + package var category: Category? + + /// The absolute edge this spacing applies to + package var edge: AbsoluteEdge + + /// Creates a new spacing key with the given category and edge + /// + /// - Parameters: + /// - category: The optional category for this spacing + /// - edge: The absolute edge this spacing applies to + package init(category: Category?, edge: AbsoluteEdge) { + self.category = category + self.edge = edge + } + } + + // MARK: - Spacing.TextMetrics + + /// Metrics that define spacing in the context of text layout + /// + /// TextMetrics captures the vertical dimensions of text, including ascenders, descenders, + /// and leading, which are used to calculate appropriate spacing between text elements. + package struct TextMetrics: Comparable { + /// The ascend height of the text (distance above the baseline) + package var ascend: CGFloat + + /// The descend height of the text (distance below the baseline) + package var descend: CGFloat + + /// The leading space between lines of text + package var leading: CGFloat + + /// The pixel length used for rounding + package var pixelLength: CGFloat + + /// Creates a new TextMetrics instance with the specified dimensions + /// + /// - Parameters: + /// - ascend: The ascend height of the text + /// - descend: The descend height of the text + /// - leading: The leading space between lines + /// - pixelLength: The pixel length used for rounding + package init(ascend: CGFloat, descend: CGFloat, leading: CGFloat, pixelLength: CGFloat) { + self.ascend = ascend + self.descend = descend + self.leading = leading + self.pixelLength = pixelLength + } + + /// The total line spacing (ascend + descend + leading) + package var lineSpacing: CGFloat { + ascend + descend + leading + } + + package static func < (lhs: TextMetrics, rhs: TextMetrics) -> Bool { + lhs.lineSpacing < rhs.lineSpacing + } + + /// Determines if this TextMetrics instance is approximately equal to another + /// + /// Two TextMetrics instances are considered approximately equal if their ascend, descend, + /// and leading values are approximately equal, regardless of pixel length. + /// + /// - Parameter other: The TextMetrics to compare with + /// - Returns: True if the metrics are approximately equal + package func isAlmostEqual(to other: TextMetrics) -> Bool { + return ascend.isAlmostEqual(to: other.ascend) + && descend.isAlmostEqual(to: other.descend) + && leading.isAlmostEqual(to: other.leading) + } + + /// Calculates the spacing between two TextMetrics instances + /// + /// This method determines the appropriate spacing between text elements based on + /// their metrics, taking into account the semantic rules for text spacing. + /// + /// - Parameters: + /// - top: The TextMetrics for the top text element + /// - bottom: The TextMetrics for the bottom text element + /// - Returns: The calculated spacing value + package static func spacing(top: TextMetrics, bottom: TextMetrics) -> CGFloat { + guard Semantics.TextSpacingUIKit0059v2.isEnabled else { + return 0 } - } else { - switch axis { - case .horizontal: _distance(from: .trailing, to: .leading, ofViewPreferring: spacing) - case .vertical: _distance(from: .bottom, to: .top, ofViewPreferring: spacing) + + var result = bottom.leading + if !top.isAlmostEqual(to: bottom) { + // NOTE: Actually this is still bottom.leading 🤔 + result = top.descend + bottom.lineSpacing - bottom.descend - top.descend - bottom.ascend } + result.round(.up, toMultipleOf: top.pixelLength) + return result + } + + package static func == (a: TextMetrics, b: TextMetrics) -> Bool { + a.ascend == b.ascend && a.descend == b.descend && a.leading == b.leading && a.pixelLength == b.pixelLength } } - private func _distance(from: Edge, to: Edge, ofViewPreferring spacing: Spacing) -> CGFloat? { - // TODO - nil + // MARK: - Spacing.Value + + /// A value that represents different types of spacing + /// + /// This enum can represent either a fixed distance or text-based metrics + /// for more sophisticated spacing calculations. + package enum Value: Comparable { + /// A fixed distance value + case distance(CGFloat) + + /// Metrics for the top of text + case topTextMetrics(TextMetrics) + + /// Metrics for the bottom of text + case bottomTextMetrics(TextMetrics) + + /// Creates a new Value instance with the given distance + /// + /// - Parameter value: The fixed distance value + @inlinable + package init(_ value: CGFloat) { + self = .distance(value) + } + + /// Returns the fixed distance value if available + /// + /// - Returns: The distance value if this is a distance type, nil otherwise + package var value: CGFloat? { + guard case let .distance(value) = self else { + return nil + } + return value + } + + /// Calculates the distance between this spacing value and another + /// + /// This method handles different combinations of spacing value types to determine + /// the appropriate distance between them. + /// + /// - Parameter other: The other spacing value + /// - Returns: The calculated distance, or nil if no distance can be determined + package func distance(to other: Value) -> CGFloat? { + switch (self, other) { + case let (.distance(a), .distance(b)): a + b + case let (.distance(a), _): a + case let (_, .distance(b)): b + case (.topTextMetrics, .topTextMetrics): nil + case let (.topTextMetrics(top), .bottomTextMetrics(bottom)): TextMetrics.spacing(top: top, bottom: bottom) + case let (.bottomTextMetrics(bottom), .topTextMetrics(top)): TextMetrics.spacing(top: top, bottom: bottom) + case (.bottomTextMetrics, .bottomTextMetrics): nil + } + } + + package static func < (a: Value, b: Value) -> Bool { + switch (a, b) { + case let (.distance(a), .distance(b)): a < b + case (.distance, .topTextMetrics): true + case (.distance, .bottomTextMetrics): true + case (.topTextMetrics, .distance): false + case let (.topTextMetrics(a), .topTextMetrics(b)): a < b + case (.topTextMetrics, .bottomTextMetrics): true + case (.bottomTextMetrics, .distance): false + case (.bottomTextMetrics, .topTextMetrics): false + case let (.bottomTextMetrics(a), .bottomTextMetrics(b)): a < b + } + } + + package static func == (a: Value, b: Value) -> Bool { + switch (a, b) { + case let (.distance(a), .distance(b)): a == b + case let (.topTextMetrics(a), .topTextMetrics(b)): a == b + case let (.bottomTextMetrics(a), .bottomTextMetrics(b)): a == b + default: false + } + } } - func reset(_ edge: Edge.Set) { - guard !edge.isEmpty else { + /// Incorporates spacing values from another Spacing instance for specified edges + /// + /// This method merges values from another Spacing instance, taking the maximum value + /// when both instances have values for the same key. + /// + /// - Parameters: + /// - edges: The set of edges to incorporate + /// - other: The other Spacing instance to incorporate values from + package mutating func incorporate(_ edges: AbsoluteEdge.Set, of other: Spacing) { + guard !edges.isEmpty else { return } - // TODO + minima.merge( + other.minima + .lazy + .filter { key, _ in + edges.contains(key.edge) + } + ) { max($0, $1) } } - func clear(_ edge: Edge.Set) { - guard !edge.isEmpty else { + /// Clears spacing values for the specified edges with the given layout direction + /// + /// - Parameters: + /// - edges: The set of edges to clear + /// - layoutDirection: The layout direction to use for resolving edges + package mutating func clear(_ edges: Edge.Set, layoutDirection: LayoutDirection) { + clear(AbsoluteEdge.Set(edges, layoutDirection: layoutDirection)) + } + + /// Clears spacing values for the specified absolute edges + /// + /// - Parameter edges: The set of absolute edges to clear + package mutating func clear(_ edges: AbsoluteEdge.Set) { + guard !edges.isEmpty else { return } - // TODO + minima = minima.filter { key, _ in + !edges.contains(key.edge) + } } - func incorporate(_ edge: Edge.Set, of spacing: Spacing) { - // TODO + /// Resets spacing values for the specified edges with the given layout direction + /// + /// This method clears the existing values and sets new default values for the specified edges. + /// + /// - Parameters: + /// - edges: The set of edges to reset + /// - layoutDirection: The layout direction to use for resolving edges + package mutating func reset(_ edges: Edge.Set, layoutDirection: LayoutDirection) { + reset(AbsoluteEdge.Set(edges, layoutDirection: layoutDirection)) + } + + /// Resets spacing values for the specified absolute edges + /// + /// This method clears the existing values and sets new default values for the specified edges. + /// + /// - Parameter edges: The set of absolute edges to reset + package mutating func reset(_ edges: AbsoluteEdge.Set) { + guard !edges.isEmpty else { + return + } + minima = minima.filter { key, _ in + !edges.contains(key.edge) + } + if edges.contains(.top) { + minima[Key(category: .edgeBelowText, edge: .top)] = .distance(0) + } + if edges.contains(.left) { + minima[Key(category: .edgeRightText, edge: .left)] = .distance(0) + } + if edges.contains(.bottom) { + minima[Key(category: .edgeAboveText, edge: .bottom)] = .distance(0) + } + if edges.contains(.right) { + minima[Key(category: .edgeLeftText, edge: .right)] = .distance(0) + } + } + + /// The dictionary of spacing values by key + package var minima: [Spacing.Key: Spacing.Value] + + /// Creates a new Spacing instance with default values + package init() { + minima = [ + Key(category: .edgeBelowText, edge: .top): .distance(0), + Key(category: .edgeAboveText, edge: .bottom): .distance(0), + Key(category: .edgeRightText, edge: .left): .distance(0), + Key(category: .edgeLeftText, edge: .right): .distance(0), + ] + } + + /// Creates a new Spacing instance with the given spacing values + /// + /// - Parameter minima: Dictionary of spacing values by key + package init(minima: [Spacing.Key: Spacing.Value]) { + self.minima = minima + } + + /// Calculates the distance to a successor view along a specified axis + /// + /// This method determines the appropriate spacing between adjacent views + /// based on their spacing preferences. + /// + /// - Parameters: + /// - axis: The axis along which to calculate the distance + /// - layoutDirection: The layout direction to use for resolving edges + /// - nextPreference: The spacing preferences of the successor view + /// - Returns: The calculated distance, or nil if no distance can be determined + package func distanceToSuccessorView(along axis: Axis, layoutDirection: LayoutDirection, preferring nextPreference: Spacing) -> CGFloat? { + let trailingEdge: AbsoluteEdge = layoutDirection == .leftToRight ? .right : .left + let leadingEdge: AbsoluteEdge = layoutDirection != .leftToRight ? .right : .left + + let bottomTrailingEdge = axis == .horizontal ? trailingEdge : .bottom + let topLeadingEdge = axis == .horizontal ? leadingEdge : .top + + let source: Spacing + let fromEdge: AbsoluteEdge + let toEdge: AbsoluteEdge + let target: Spacing + + if minima.count >= nextPreference.minima.count { + source = nextPreference + fromEdge = topLeadingEdge + toEdge = bottomTrailingEdge + target = self + } else { + source = self + fromEdge = bottomTrailingEdge + toEdge = topLeadingEdge + target = nextPreference + } + return source._distance(from: fromEdge, to: toEdge, ofViewPreferring: target) + } + + private func _distance(from fromEdge: AbsoluteEdge, to toEdge: AbsoluteEdge, ofViewPreferring nextPreference: Spacing) -> CGFloat? { + let (hasValue, distance) = minima.reduce((false, -Double.infinity)) { partialResult, pair in + let (_, distance) = partialResult + let (key, value) = pair + guard let category = key.category, key.edge == fromEdge else { + return partialResult + } + let toEdgeKey = Key(category: category, edge: toEdge) + guard let nextValue = nextPreference.minima[toEdgeKey] else { + return partialResult + } + guard let newDistance = value.distance(to: nextValue) else { + return partialResult + } + return (true, max(distance, newDistance)) + } + guard !hasValue else { + return distance + } + let fromValue = minima[Key(category: nil, edge: fromEdge)]?.value + let toValue = nextPreference.minima[Key(category: nil, edge: toEdge)]?.value + guard fromValue != nil || toValue != nil else { + return nil + } + return max(fromValue ?? -.infinity, toValue ?? -.infinity) } } -// MARK: - Spacing.Key +@_spi(ForOpenSwiftUIOnly) +extension Spacing: CustomStringConvertible { + public var description: String { + guard !minima.isEmpty else { + return "Spacing (empty)" + } + var result = "Spacing [\n" + var sortedKeys = Array(minima.keys) + sortedKeys.sort { a, b in + // Sort by edge first + if a.edge.rawValue != b.edge.rawValue { + return a.edge.rawValue < b.edge.rawValue + } + // NOTE: SwiftUICore.Spacing only sort edge currently, we sort the category so that the unit test result is stable. + // Then sort by category name (nil categories come first) + guard let aType = a.category?.type, let bType = b.category?.type else { + return a.category == nil && b.category != nil + } + return String(describing: aType) < String(describing: bType) + } + for key in sortedKeys { + let value = minima[key]! + let categoryName: String + if let category = key.category { + categoryName = String(describing: category.type) + } else { + categoryName = "default" + } + let valueDescription: String + switch value { + case let .distance(distance): + valueDescription = "\(distance)" + case let .topTextMetrics(metrics), let .bottomTextMetrics(metrics): + valueDescription = "\(metrics)" + } + result += " (\(categoryName), \(key.edge)) : \(valueDescription)\n" + } + result += "]" + return result + } +} +@_spi(ForOpenSwiftUIOnly) extension Spacing { - struct Key: Hashable { - var category: Category? - var edge: Edge + /// Determines whether the spacing values are symmetric with respect to layout direction + /// + /// This property checks if horizontal spacing values (left and right) would produce + /// the same visual results regardless of whether the layout direction is left-to-right + /// or right-to-left. + /// + /// A spacing is considered layout direction symmetric when: + /// - It has no horizontal spacing values at all (only top/bottom) + /// - It has equal values for both left and right edges with the same category + /// - It has matching pairs of edge-category combinations + package var isLayoutDirectionSymmetric: Bool { + var horizontalEdgesByCategory: [Category?: (left: Value?, right: Value?)] = [:] + for (key, value) in minima where key.edge == .left || key.edge == .right { + let category = key.category + var pair = horizontalEdgesByCategory[category] ?? (left: nil, right: nil) + if key.edge == .left { + pair.left = value + } else { + pair.right = value + } + horizontalEdgesByCategory[category] = pair + } + let values = horizontalEdgesByCategory.values + guard !values.isEmpty else { + return true + } + return values.allSatisfy { $0.left == $0.right } } } -// MARK: - Spacing.Category +// MARK: - Spacing + Extension +@_spi(ForOpenSwiftUIOnly) extension Spacing { - struct Category: Hashable { - let id: ObjectIdentifier + /// A spacing instance with zero values for all edges + package static let zero: Spacing = .init(minima: [ + Key(category: nil, edge: .left): .distance(0), + Key(category: nil, edge: .right): .distance(0), + Key(category: nil, edge: .top): .distance(0), + Key(category: nil, edge: .bottom): .distance(0), + ]) + + /// Creates a spacing instance with the same value for all edges + /// + /// - Parameter value: The spacing value to apply to all edges + /// - Returns: A new Spacing instance with the specified value + package static func all(_ value: CGFloat) -> Spacing { + Spacing(minima: [ + Key(category: nil, edge: .left): .distance(value), + Key(category: nil, edge: .right): .distance(value), + Key(category: nil, edge: .top): .distance(value), + Key(category: nil, edge: .bottom): .distance(value), + ]) + } + + /// Creates a spacing instance with the specified value for horizontal edges only + /// + /// - Parameter value: The spacing value to apply to horizontal edges + /// - Returns: A new Spacing instance with the specified horizontal spacing + package static func horizontal(_ value: CGFloat) -> Spacing { + Spacing(minima: [ + Key(category: nil, edge: .left): .distance(value), + Key(category: nil, edge: .right): .distance(value), + ]) + } + + /// Creates a spacing instance with the specified value for vertical edges only + /// + /// - Parameter value: The spacing value to apply to vertical edges + /// - Returns: A new Spacing instance with the specified vertical spacing + package static func vertical(_ value: CGFloat) -> Spacing { + Spacing(minima: [ + Key(category: nil, edge: .top): .distance(value), + Key(category: nil, edge: .bottom): .distance(value), + ]) } } +// MARK: - Spacing.Category + Extension + +@_spi(ForOpenSwiftUIOnly) extension Spacing.Category { private enum TextToText {} - private enum TextBaseline {} - private enum EdgeBelowText {} private enum EdgeAboveText {} - static let textToText: Self = .init(id: .init(TextToText.self)) - static let textBaseline: Self = .init(id: .init(TextBaseline.self)) - static let edgeBelowText: Self = .init(id: .init(EdgeBelowText.self)) - static let edgeAboveText: Self = .init(id: .init(EdgeAboveText.self)) + private enum EdgeBelowText {} + private enum TextBaseline {} + private enum EdgeLeftText {} + private enum EdgeRightText {} + private enum LeftTextBaseline {} + private enum RightTextBaseline {} + + /// A category for spacing between text elements + package static var textToText = Spacing.Category(TextToText.self) + + /// A category for spacing above text elements + package static var edgeAboveText = Spacing.Category(EdgeAboveText.self) + + /// A category for spacing below text elements + package static var edgeBelowText = Spacing.Category(EdgeBelowText.self) + + /// A category for text baseline spacing + package static var textBaseline = Spacing.Category(TextBaseline.self) + + /// A category for spacing to the left of text + package static var edgeLeftText = Spacing.Category(EdgeLeftText.self) + + /// A category for spacing to the right of text + package static var edgeRightText = Spacing.Category(EdgeRightText.self) + + /// A category for left text baseline spacing + package static var leftTextBaseline = Spacing.Category(LeftTextBaseline.self) + + /// A category for right text baseline spacing + package static var rightTextBaseline = Spacing.Category(RightTextBaseline.self) } diff --git a/Sources/OpenSwiftUICore/Layout/LayoutComputer/LayoutComputer.swift b/Sources/OpenSwiftUICore/Layout/LayoutComputer/LayoutComputer.swift index 46811c73..2f9ec86a 100644 --- a/Sources/OpenSwiftUICore/Layout/LayoutComputer/LayoutComputer.swift +++ b/Sources/OpenSwiftUICore/Layout/LayoutComputer/LayoutComputer.swift @@ -59,10 +59,7 @@ extension LayoutComputer { } override func spacing() -> Spacing { - Spacing(minima: [ - .init(category: .edgeBelowText, edge: .top) : .zero, - .init(category: .edgeAboveText, edge: .bottom) : .zero, - ]) + Spacing() } override func sizeThatFits(_ size: _ProposedSize) -> CGSize { diff --git a/Tests/OpenSwiftUICoreTests/Layout/Geometry/SpacingTests.swift b/Tests/OpenSwiftUICoreTests/Layout/Geometry/SpacingTests.swift new file mode 100644 index 00000000..fa54d65f --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Layout/Geometry/SpacingTests.swift @@ -0,0 +1,651 @@ +// +// SpacingTests.swift +// OpenSwiftUICoreTests +// +// Audited for iOS 18.0 +// Status: Complete + +import Numerics +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore +import Testing + +struct SpacingTests { + // MARK: - Category Tests + + struct CategoryTests { + @Test + func equality() { + let textToText1 = Spacing.Category.textToText + let textToText2 = Spacing.Category.textToText + let edgeAboveText = Spacing.Category.edgeAboveText + + #expect(textToText1 == textToText2) + #expect(textToText1 != edgeAboveText) + } + + @Test + func predefinedCategories() { + // Testing all predefined categories + let categories = [ + Spacing.Category.textToText, + Spacing.Category.edgeAboveText, + Spacing.Category.edgeBelowText, + Spacing.Category.textBaseline, + Spacing.Category.edgeLeftText, + Spacing.Category.edgeRightText, + Spacing.Category.leftTextBaseline, + Spacing.Category.rightTextBaseline, + ] + + // Ensure all categories are unique + var uniqueCategories = Set() + for category in categories { + uniqueCategories.insert(category) + } + + #expect(uniqueCategories.count == 8) + } + } + + // MARK: - Key Tests + + struct KeyTests { + @Test + func initialization() { + let key1 = Spacing.Key(category: .textToText, edge: .top) + #expect(key1.category == .textToText) + #expect(key1.edge == .top) + + let key2 = Spacing.Key(category: nil, edge: .bottom) + #expect(key2.category == nil) + #expect(key2.edge == .bottom) + + #expect(key1 != key2) + } + + @Test + func equality() { + let key1 = Spacing.Key(category: .textToText, edge: .top) + let key2 = Spacing.Key(category: .textToText, edge: .top) + let key3 = Spacing.Key(category: .edgeAboveText, edge: .top) + let key4 = Spacing.Key(category: .textToText, edge: .bottom) + + #expect(key1 == key2) + #expect(key1 != key3) + #expect(key1 != key4) + } + } + + // MARK: - TextMetrics Tests + + struct TextMetricsTests { + typealias TextMetrics = Spacing.TextMetrics + + @Test + func isAlmostEqual() { + let m1 = TextMetrics(ascend: 1, descend: 2, leading: 3, pixelLength: 4) + let m2 = TextMetrics(ascend: 1, descend: 2, leading: 3, pixelLength: 5) + #expect(m1.isAlmostEqual(to: m2)) + + let m3 = TextMetrics(ascend: 1.1, descend: 2, leading: 3, pixelLength: 4) + #expect(!m1.isAlmostEqual(to: m3)) + } + + @Test + func spacing() { + let m1 = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + let m2 = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + let m3 = TextMetrics(ascend: 7, descend: 11, leading: 17, pixelLength: 23) + #expect(TextMetrics.spacing(top: m1, bottom: m2).isApproximatelyEqual(to: 13)) + #expect(TextMetrics.spacing(top: m1, bottom: m3).isApproximatelyEqual(to: 26)) + #expect(TextMetrics.spacing(top: m3, bottom: m1).isApproximatelyEqual(to: 23)) + } + + @Test + func lineSpacing() { + let metris = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + #expect(metris.lineSpacing.isApproximatelyEqual(to: 10)) + } + + @Test + func comparison() { + let m1 = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + let m2 = TextMetrics(ascend: 2, descend: 3, leading: 4, pixelLength: 10) + let m3 = TextMetrics(ascend: 5, descend: 3, leading: 2, pixelLength: 13) + let m4 = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + + #expect(m2 < m1) + + #expect(!(m1 < m3)) + #expect(!(m1 > m3)) + #expect(m1 != m3) + + #expect(m1 == m4) + } + } + + // MARK: - Value Tests + + struct ValueTests { + typealias TextMetrics = Spacing.TextMetrics + typealias Value = Spacing.Value + + @Test + func initialization() { + _ = Value(10.0) + let metrics = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + _ = Value.topTextMetrics(metrics) + _ = Value.bottomTextMetrics(metrics) + } + + @Test + func getValue() throws { + let v1 = Value(10.0) + let metrics = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + let v2 = Value.topTextMetrics(metrics) + + let value = try #require(v1.value) + #expect(value.isApproximatelyEqual(to: 10.0)) + #expect(v2.value == nil) + } + + @Test + func distance() throws { + let d1 = Value(10.0) + let d2 = Value(15.0) + #expect(d1.distance(to: d2) == 25.0) + + let tm1 = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + let tm2 = TextMetrics(ascend: 7, descend: 11, leading: 17, pixelLength: 23) + + let top1 = Value.topTextMetrics(tm1) + let bottom1 = Value.bottomTextMetrics(tm1) + let top2 = Value.topTextMetrics(tm2) + let bottom2 = Value.bottomTextMetrics(tm2) + + #expect(top1.distance(to: top2) == nil) + #expect(bottom1.distance(to: bottom2) == nil) + + #expect(top1.distance(to: bottom2)?.isApproximatelyEqual(to: 26) == true) + #expect(bottom1.distance(to: top2)?.isApproximatelyEqual(to: 23) == true) + + #expect(d1.distance(to: top1) == 10.0) + #expect(top1.distance(to: d1) == 10.0) + } + + @Test + func comparison() { + let d1 = Value(10.0) + let d2 = Value(20.0) + + // Distance comparison + #expect(d1 < d2) + #expect(!(d2 < d1)) + #expect(d1 == Value(10.0)) + + // TextMetrics comparison + let tm1 = TextMetrics(ascend: 2, descend: 3, leading: 5, pixelLength: 13) + let tm2 = TextMetrics(ascend: 7, descend: 11, leading: 17, pixelLength: 23) + + let top1 = Value.topTextMetrics(tm1) + let top2 = Value.topTextMetrics(tm2) + let bottom1 = Value.bottomTextMetrics(tm1) + let bottom2 = Value.bottomTextMetrics(tm2) + + // TextMetrics of same type comparison + #expect(top1 < top2) + #expect(bottom1 < bottom2) + + // Mixed type comparisons + #expect(d1 < top1) // Distance < TopTextMetrics + #expect(d1 < bottom1) // Distance < BottomTextMetrics + #expect(top1 < bottom1) // TopTextMetrics < BottomTextMetrics + + // Equality + #expect(top1 == Value.topTextMetrics(tm1)) + #expect(bottom1 == Value.bottomTextMetrics(tm1)) + #expect(top1 != bottom1) + #expect(d1 != top1) + } + } + + // MARK: - Spacing Modification Tests + + @Test + func incorporate() throws { + var spacing1 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(10), + ]) + + let other = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(8), + Spacing.Key(category: nil, edge: .left): .distance(3), + Spacing.Key(category: nil, edge: .right): .distance(15), + ]) + + // Incorporate all edges + spacing1.incorporate(.all, of: other) + #expect(spacing1 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(8), + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: nil, edge: .right): .distance(15), + ])) + + // Test with partial edge set + var spacing2 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + ]) + + spacing2.incorporate([.left], of: other) + #expect(spacing2 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(3), + ])) + + // Test with empty edge set + var spacing3 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + ]) + + spacing3.incorporate([], of: other) + #expect(spacing3 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + ])) + } + + @Test + func clear() throws { + // Test clear with AbsoluteEdge.Set + + var spacing1 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: nil, edge: .bottom): .distance(15), + Spacing.Key(category: nil, edge: .right): .distance(20), + ]) + + // Clear vertical edges + spacing1.clear([.top, .bottom]) + + #expect(spacing1 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: nil, edge: .right): .distance(20), + ])) + + // Test clear with Edge.Set and LayoutDirection + var spacing2 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: nil, edge: .right): .distance(15), + ]) + var spacing3 = spacing2 + + // In LTR, .leading is .left + spacing2.clear(.leading, layoutDirection: .leftToRight) + #expect(spacing2 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .right): .distance(15), + ])) + // In RTL, .leading is .right + spacing3.clear(.leading, layoutDirection: .rightToLeft) + #expect(spacing3 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(10), + ])) + + // Test with empty edge set + var spacing4 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + ]) + + spacing4.clear([]) + #expect(spacing4 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + ])) + } + + @Test + func reset() throws { + // Test reset with AbsoluteEdge.Set + var spacing1 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: nil, edge: .bottom): .distance(15), + Spacing.Key(category: nil, edge: .right): .distance(20), + ]) + + spacing1.reset([.top, .bottom]) + #expect(spacing1 == Spacing(minima: [ + Spacing.Key(category: .edgeBelowText, edge: .top): .distance(0), + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: .edgeAboveText, edge: .bottom): .distance(0), + Spacing.Key(category: nil, edge: .right): .distance(20), + ])) + + // Test reset with Edge.Set and LayoutDirection + var spacing2 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: nil, edge: .right): .distance(15), + ]) + var spacing3 = spacing2 + + // In LTR, .leading is .left + spacing2.reset(.leading, layoutDirection: .leftToRight) + #expect(spacing2 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: .edgeRightText, edge: .left): .distance(0), + Spacing.Key(category: nil, edge: .right): .distance(15), + ])) + + // In RTL, .leading is .right + spacing3.reset(.leading, layoutDirection: .rightToLeft) + #expect(spacing3 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + Spacing.Key(category: nil, edge: .left): .distance(10), + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(0), + ])) + + // Test with empty edge set + var spacing4 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + ]) + + spacing4.reset([]) + #expect(spacing4 == Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(5), + ])) + } + + // MARK: - Spacing Initialization Tests + + @Test + func defaultInitialization() throws { + let spacing = Spacing() + + // Verify default values are set for text edge categories + let keys = [ + Spacing.Key(category: .edgeBelowText, edge: .top), + Spacing.Key(category: .edgeRightText, edge: .left), + Spacing.Key(category: .edgeAboveText, edge: .bottom), + Spacing.Key(category: .edgeLeftText, edge: .right), + ] + + for key in keys { + let value = try #require(spacing.minima[key]) + #expect(value == .distance(0)) + } + } + + @Test + func customInitialization() throws { + let minima: [Spacing.Key: Spacing.Value] = [ + Spacing.Key(category: nil, edge: .top): .distance(10), + Spacing.Key(category: nil, edge: .left): .distance(20), + ] + + let spacing = Spacing(minima: minima) + + #expect(spacing.minima.count == 2) + + let topKey = Spacing.Key(category: nil, edge: .top) + let topValue = try #require(spacing.minima[topKey]) + #expect(topValue == .distance(10)) + + let leftKey = Spacing.Key(category: nil, edge: .left) + let leftValue = try #require(spacing.minima[leftKey]) + #expect(leftValue == .distance(20)) + } + + @Test + func distanceToSuccessorView() { + do { + let spacing1 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(2), + Spacing.Key(category: nil, edge: .left): .distance(3), + Spacing.Key(category: nil, edge: .bottom): .distance(7), + Spacing.Key(category: nil, edge: .right): .distance(11), + ]) + let spacing2 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(13), + Spacing.Key(category: nil, edge: .left): .distance(17), + Spacing.Key(category: nil, edge: .bottom): .distance(21), + Spacing.Key(category: nil, edge: .right): .distance(23), + ]) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing2)!.isApproximatelyEqual(to: 17)) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing2)!.isApproximatelyEqual(to: 23)) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2)!.isApproximatelyEqual(to: 13)) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2)!.isApproximatelyEqual(to: 13)) + + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing1)!.isApproximatelyEqual(to: 23)) + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing1)!.isApproximatelyEqual(to: 17)) + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing1)!.isApproximatelyEqual(to: 21)) + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing1)!.isApproximatelyEqual(to: 21)) + } + do { + let spacing1 = Spacing(minima: [ + Spacing.Key(category: .edgeBelowText, edge: .top): .distance(2), + Spacing.Key(category: .edgeAboveText, edge: .bottom): .distance(3), + Spacing.Key(category: .edgeRightText, edge: .left): .distance(5), + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(7), + ]) + let spacing2 = Spacing(minima: [ + Spacing.Key(category: .edgeBelowText, edge: .top): .distance(13), + Spacing.Key(category: .edgeAboveText, edge: .left): .distance(17), + Spacing.Key(category: .edgeRightText, edge: .bottom): .distance(21), + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(23), + ]) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing2) == nil) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing2) == nil) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2) == nil) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2) == nil) + + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing1) == nil) + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing1) == nil) + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing1) == nil) + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing1) == nil) + } + do { + let spacing1 = Spacing(minima: [ + Spacing.Key(category: nil, edge: .top): .distance(2), + Spacing.Key(category: nil, edge: .left): .distance(3), + Spacing.Key(category: .edgeRightText, edge: .left): .distance(5), + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(7), + ]) + let spacing2 = Spacing(minima: [ + Spacing.Key(category: .edgeBelowText, edge: .top): .distance(13), + Spacing.Key(category: .edgeAboveText, edge: .left): .distance(17), + Spacing.Key(category: nil, edge: .bottom): .distance(21), + Spacing.Key(category: nil, edge: .right): .distance(23), + ]) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing2) == nil) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing2)!.isApproximatelyEqual(to: 23)) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2) == nil) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2) == nil) + + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing1)!.isApproximatelyEqual(to: 23)) + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing1) == nil) + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing1)!.isApproximatelyEqual(to: 21)) + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing1)!.isApproximatelyEqual(to: 21)) + } + } + + @Test + func distanceToSuccessorView2() { + do { + let m = Spacing.TextMetrics(ascend: 1, descend: 3, leading: 5, pixelLength: 7) + let spacing1 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(2), + ]) + let spacing2 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .left): .distance(13), + Spacing.Key(category: .edgeRightText, edge: .left): .bottomTextMetrics(m), + ]) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing2)!.isApproximatelyEqual(to: 15)) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing2) == nil) + + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing1) == nil) + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing1)!.isApproximatelyEqual(to: 15)) + } + do { + let m = Spacing.TextMetrics(ascend: 1, descend: 3, leading: 5, pixelLength: 7) + let spacing1 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .left): .distance(2), + ]) + let spacing2 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(13), + Spacing.Key(category: .edgeRightText, edge: .right): .bottomTextMetrics(m), + ]) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing2) == nil) + #expect(spacing1.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing2)!.isApproximatelyEqual(to: 15)) + + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .leftToRight, preferring: spacing1)!.isApproximatelyEqual(to: 15)) + #expect(spacing2.distanceToSuccessorView(along: .horizontal, layoutDirection: .rightToLeft, preferring: spacing1) == nil) + } + do { + let m = Spacing.TextMetrics(ascend: 1, descend: 3, leading: 5, pixelLength: 7) + let spacing1 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .bottom): .distance(2), + ]) + let spacing2 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .top): .distance(13), + Spacing.Key(category: .edgeRightText, edge: .top): .bottomTextMetrics(m), + ]) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2)!.isApproximatelyEqual(to: 15)) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .rightToLeft, preferring: spacing2)!.isApproximatelyEqual(to: 15)) + + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .rightToLeft, preferring: spacing1) == nil) + #expect(spacing2.distanceToSuccessorView(along: .vertical, layoutDirection: .rightToLeft, preferring: spacing1) == nil) + } + do { + let m1 = Spacing.TextMetrics(ascend: 1, descend: 3, leading: 5, pixelLength: 3) + let m2 = Spacing.TextMetrics(ascend: 9999, descend: 9999, leading: 5, pixelLength: 9999) + let m3 = Spacing.TextMetrics(ascend: 9999, descend: 9999, leading: 1, pixelLength: 9999) + + let spacing1 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .bottom): .bottomTextMetrics(m1), + ]) + let spacing2 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .top): .distance(13), + Spacing.Key(category: .edgeRightText, edge: .top): .topTextMetrics(m2), + ]) + let spacing3 = Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .top): .distance(13), + Spacing.Key(category: .edgeRightText, edge: .top): .topTextMetrics(m3), + ]) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing2)!.isApproximatelyEqual(to: 13)) + #expect(spacing1.distanceToSuccessorView(along: .vertical, layoutDirection: .leftToRight, preferring: spacing3)!.isApproximatelyEqual(to: 13)) + } + } + + // MARK: - description Tests + + @Test + func description() { + // zero spacing + #expect(Spacing.zero.description == #""" + Spacing [ + (default, top) : 0.0 + (default, left) : 0.0 + (default, bottom) : 0.0 + (default, right) : 0.0 + ] + """#) + + // Default spacing + #expect(Spacing().description == #""" + Spacing [ + (EdgeBelowText, top) : 0.0 + (EdgeRightText, left) : 0.0 + (EdgeAboveText, bottom) : 0.0 + (EdgeLeftText, right) : 0.0 + ] + """#) + + // Empty case + #expect(Spacing(minima: [:]).description == #""" + Spacing (empty) + """#) + + // spacing with metircs + let m = Spacing.TextMetrics(ascend: 1, descend: 3, leading: 5, pixelLength: 7) + #expect(Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .left): .distance(13), + Spacing.Key(category: nil, edge: .left): .bottomTextMetrics(m), + Spacing.Key(category: .edgeAboveText, edge: .left): .topTextMetrics(m), + ]).description == #""" + Spacing [ + (default, left) : TextMetrics(ascend: 1.0, descend: 3.0, leading: 5.0, pixelLength: 7.0) + (EdgeAboveText, left) : TextMetrics(ascend: 1.0, descend: 3.0, leading: 5.0, pixelLength: 7.0) + (EdgeLeftText, left) : 13.0 + ] + """#) + + // spacing with key ordering + enum UnknownCategory {} + + #expect(Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(0), + Spacing.Key(category: nil, edge: .right): .distance(0), + Spacing.Key(category: .edgeAboveText, edge: .right): .distance(0), + Spacing.Key(category: Spacing.Category(UnknownCategory.self), edge: .right): .distance(0), + Spacing.Key(category: .edgeLeftText, edge: .left): .distance(0), + Spacing.Key(category: nil, edge: .left): .distance(0), + Spacing.Key(category: .edgeAboveText, edge: .left): .distance(0), + Spacing.Key(category: Spacing.Category(UnknownCategory.self), edge: .left): .distance(0), + ]).description == #""" + Spacing [ + (default, left) : 0.0 + (EdgeAboveText, left) : 0.0 + (EdgeLeftText, left) : 0.0 + (UnknownCategory, left) : 0.0 + (default, right) : 0.0 + (EdgeAboveText, right) : 0.0 + (EdgeLeftText, right) : 0.0 + (UnknownCategory, right) : 0.0 + ] + """#) + } + + // MARK: - isLayoutDirectionSymmetric Tests + + @Test + func layoutDirectionSymmetry() { + #expect(!Spacing().isLayoutDirectionSymmetric) + #expect(Spacing.horizontal(10).isLayoutDirectionSymmetric) + #expect(Spacing.vertical(10).isLayoutDirectionSymmetric) + #expect(Spacing.all(10).isLayoutDirectionSymmetric) + + #expect(Spacing(minima: [Spacing.Key(category: nil, edge: .top): .distance(0)]).isLayoutDirectionSymmetric) + #expect(Spacing(minima: [Spacing.Key(category: nil, edge: .top): .distance(5)]).isLayoutDirectionSymmetric) + + #expect(!Spacing(minima: [Spacing.Key(category: nil, edge: .left): .distance(0)]).isLayoutDirectionSymmetric) + #expect(!Spacing(minima: [Spacing.Key(category: nil, edge: .left): .distance(5)]).isLayoutDirectionSymmetric) + + #expect(!Spacing(minima: [ + Spacing.Key(category: nil, edge: .left): .distance(5), + Spacing.Key(category: nil, edge: .right): .distance(6), + ]).isLayoutDirectionSymmetric) + #expect(Spacing(minima: [ + Spacing.Key(category: nil, edge: .left): .distance(5), + Spacing.Key(category: nil, edge: .right): .distance(5), + ]).isLayoutDirectionSymmetric) + + #expect(!Spacing(minima: [ + Spacing.Key(category: .edgeAboveText, edge: .left): .distance(5), + Spacing.Key(category: .edgeLeftText, edge: .right): .distance(5), + ]).isLayoutDirectionSymmetric) + + #expect(Spacing(minima: [ + Spacing.Key(category: .edgeAboveText, edge: .left): .distance(5), + Spacing.Key(category: .edgeAboveText, edge: .right): .distance(5), + ]).isLayoutDirectionSymmetric) + + #expect(!Spacing(minima: [ + Spacing.Key(category: .edgeLeftText, edge: .left): .distance(5), + Spacing.Key(category: .edgeRightText, edge: .right): .distance(5), + ]).isLayoutDirectionSymmetric) + } +}