mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 19:45:49 +00:00
Add MockView and SplitTreeTests (#10778)
This PR introduces unit tests and a supporting Mock NSView for testing
the SplitTree implementation in Swift. It includes 51 tests which
achieve approximately 93.13% (949/1019) coverage of SplitTree.swift's
branches.
<details>
<summary>Coverage</summary>
<pre>
./ghostty/macos/Sources/Features/Splits/SplitTree.swift 93.13%
(949/1019)
SplitTree.Path.isEmpty.getter 100.00% (1/1)
SplitTree.isEmpty.getter 100.00% (3/3)
SplitTree.isSplit.getter 100.00% (3/3)
SplitTree.init() 100.00% (3/3)
SplitTree.init(view:) 100.00% (3/3)
SplitTree.contains(_:) 100.00% (4/4)
SplitTree.inserting(view:at:direction:) 100.00% (6/6)
SplitTree.find(id:) 100.00% (4/4)
SplitTree.removing(_:) 93.75% (15/16)
SplitTree.replacing(node:with:) 93.75% (15/16)
SplitTree.focusTarget(for:from:) 82.14% (46/56)
closure #1 in SplitTree.focusTarget(for:from:) 100.00% (1/1)
closure #2 in SplitTree.focusTarget(for:from:) 100.00% (1/1)
closure #3 in SplitTree.focusTarget(for:from:) 100.00% (3/3)
implicit closure #1 in SplitTree.focusTarget(for:from:) 0.00% (0/1)
SplitTree.equalized() 100.00% (5/5)
SplitTree.resizing(node:by:in:with:) 92.00% (69/75)
closure #1 in SplitTree.resizing(node:by:in:with:) 100.00% (1/1)
SplitTree.viewBounds() 100.00% (4/4)
SplitTree.init(from:) 76.00% (19/25)
SplitTree.encode(to:) 100.00% (15/15)
SplitTree.Node.find(id:) 100.00% (13/13)
SplitTree.Node.node(view:) 88.89% (16/18)
SplitTree.Node.path(to:) 100.00% (32/32)
search #1 <A>(_:) in SplitTree.Node.path(to:) 100.00% (27/27)
SplitTree.Node.node(at:) 89.47% (17/19)
SplitTree.Node.inserting(view:at:direction:) 86.84% (33/38)
SplitTree.Node.replacingNode(at:with:) 100.00% (43/43)
replaceInner #1 <A>(current:pathOffset:) in
SplitTree.Node.replacingNode(at:with:) 96.67% (29/30)
SplitTree.Node.remove(_:) 70.27% (26/37)
implicit closure #1 in SplitTree.Node.remove(_:) 100.00% (1/1)
SplitTree.Node.resizing(to:) 100.00% (16/16)
SplitTree.Node.leftmostLeaf() 87.50% (7/8)
SplitTree.Node.rightmostLeaf() 87.50% (7/8)
SplitTree.Node.equalize() 100.00% (4/4)
SplitTree.Node.equalizeWithWeight() 100.00% (30/30)
SplitTree.Node.weightForDirection(_:) 83.33% (10/12)
SplitTree.Node.calculateViewBounds(in:) 100.00% (50/50)
SplitTree.Node.viewBounds() 100.00% (26/26)
SplitTree.Node.spatial(within:) 100.00% (18/18)
SplitTree.Node.dimensions() 80.77% (21/26)
SplitTree.Node.spatialSlots(in:) 100.00% (53/53)
SplitTree.Spatial.slots(in:from:) 100.00% (47/47)
closure #1 in SplitTree.Spatial.slots(in:from:) 100.00% (1/1)
distance #1 <A>(from:to:) in SplitTree.Spatial.slots(in:from:) 100.00%
(6/6)
closure #2 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
implicit closure #1 in closure #2 in SplitTree.Spatial.slots(in:from:)
100.00% (1/1)
closure #3 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
closure #4 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
implicit closure #1 in closure #4 in SplitTree.Spatial.slots(in:from:)
100.00% (1/1)
closure #5 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
closure #6 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
implicit closure #1 in closure #6 in SplitTree.Spatial.slots(in:from:)
100.00% (1/1)
closure #7 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
closure #8 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
implicit closure #1 in closure #8 in SplitTree.Spatial.slots(in:from:)
100.00% (1/1)
closure #9 in SplitTree.Spatial.slots(in:from:) 100.00% (3/3)
SplitTree.Spatial.doesBorder(side:from:) 100.00% (20/20)
closure #1 in SplitTree.Spatial.doesBorder(side:from:) 100.00% (1/1)
closure #2 in SplitTree.Spatial.doesBorder(side:from:) 100.00% (3/3)
static SplitTree.Node.== infix(_:_:) 100.00% (13/13)
SplitTree.Node.init(from:) 66.67% (12/18)
SplitTree.Node.encode(to:) 100.00% (11/11)
SplitTree.Node.leaves() 100.00% (9/9)
SplitTree.makeIterator() 100.00% (3/3)
implicit closure #1 in SplitTree.makeIterator() 100.00% (1/1)
SplitTree.Node.makeIterator() 0.00% (0/3)
SplitTree.startIndex.getter 100.00% (3/3)
SplitTree.endIndex.getter 100.00% (3/3)
implicit closure #1 in SplitTree.endIndex.getter 100.00% (1/1)
SplitTree.subscript.getter 100.00% (5/5)
implicit closure #1 in SplitTree.subscript.getter 100.00% (1/1)
implicit closure #2 in implicit closure #1 in SplitTree.subscript.getter
100.00% (1/1)
implicit closure #3 in SplitTree.subscript.getter 0.00% (0/1)
implicit closure #4 in SplitTree.subscript.getter 0.00% (0/1)
SplitTree.index(after:) 100.00% (4/4)
implicit closure #1 in SplitTree.index(after:) 100.00% (1/1)
implicit closure #2 in SplitTree.index(after:) 0.00% (0/1)
SplitTree.Node.structuralIdentity.getter 100.00% (3/3)
SplitTree.Node.StructuralIdentity.init(_:) 100.00% (3/3)
static SplitTree.Node.StructuralIdentity.== infix(_:_:) 100.00% (3/3)
SplitTree.Node.StructuralIdentity.hash(into:) 100.00% (3/3)
SplitTree.Node.isStructurallyEqual(to:) 100.00% (18/18)
implicit closure #1 in SplitTree.Node.isStructurallyEqual(to:) 100.00%
(1/1)
implicit closure #2 in SplitTree.Node.isStructurallyEqual(to:) 100.00%
(1/1)
SplitTree.Node.hashStructure(into:) 100.00% (14/14)
SplitTree.structuralIdentity.getter 100.00% (3/3)
SplitTree.StructuralIdentity.init(_:) 100.00% (4/4)
static SplitTree.StructuralIdentity.== infix(_:_:) 100.00% (4/4)
implicit closure #1 in static SplitTree.StructuralIdentity.==
infix(_:_:) 100.00% (1/1)
SplitTree.StructuralIdentity.hash(into:) 80.00% (8/10)
static SplitTree.StructuralIdentity.areNodesStructurallyEqual(_:_:)
90.00% (9/10)
</pre>
</details>
I chose this as a good place to start contributing to Ghostty because I
was curious about the macOS implementation, and there was a specific
request for help with testing (#7879).
My process for writing the tests was basically reading
[SplitTree.swift](./macos/Sources/Features/Splits/SplitTree.swift) to
understand it, then writing tests for each high-level method and
checking against code coverage to capture all the code paths:
## Running
```bash
rm -rf /tmp/ghostty-test.xcresult
xcodebuild -project macos/Ghostty.xcodeproj \
-scheme GhosttyTest \
-configuration Debug \
test \
-destination 'platform=macOS' \
-enableCodeCoverage YES \
-resultBundlePath /tmp/ghostty-test.xcresult \
-only-testing:GhosttyTests/SplitTreeTests \
2>&1 | xcbeautify
```
## Coverage
```bash
xcrun xccov view --report /tmp/ghostty-test.xcresult | grep 'SplitTree\.'
```
This was originally implemented in [~38
commits](https://github.com/pouwerkerk/ghostty/pull/1/commits), but I
squashed them down to 1 commit for easier review.
## AI Disclosure
The tests were written by me, but I used Opus 4.6 to explain some parts
of the code, and then finally to provide feedback on the tests. It
suggested tests for `nodeStructuralIdentityInSet` and
`nodeStructuralIdentityDistinguishesLeaves` as well as [the
Parameterized
test](6a0bca43f6),
`resizingAdjustsRatio`, which seemed like a clever way to collapse 12
individual tests into 3 parameterized ones that still run 12 cases
total. I didn't know this feature existed, and it seems like a great way
to write tests that are more maintainable. I read this relatively new
feature in the [Swift
Docs](https://developer.apple.com/documentation/testing/parameterizedtesting).
I find this to be a particularly useful feature of Claude/related
agents, where it can suggest better ways of writing something in a more
idiomatic way, and it taught me something new, which is always fun.
I'm more than happy to continue work on tests for #7879 and always
welcome to any feedback you have.
This commit is contained in:
666
macos/Tests/Splits/SplitTreeTests.swift
Normal file
666
macos/Tests/Splits/SplitTreeTests.swift
Normal file
@@ -0,0 +1,666 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Ghostty
|
||||
|
||||
class MockView: NSView, Codable, Identifiable {
|
||||
let id: UUID
|
||||
|
||||
init(id: UUID = UUID()) {
|
||||
self.id = id
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
enum CodingKeys: CodingKey { case id }
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try c.decode(UUID.self, forKey: .id)
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
}
|
||||
}
|
||||
|
||||
struct SplitTreeTests {
|
||||
/// Creates a two-view horizontal split tree (view1 | view2).
|
||||
private func makeHorizontalSplit() throws -> (SplitTree<MockView>, MockView, MockView) {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
return (tree, view1, view2)
|
||||
}
|
||||
|
||||
// MARK: - Empty and Non-Empty
|
||||
|
||||
@Test func emptyTreeIsEmpty() {
|
||||
let tree = SplitTree<MockView>()
|
||||
#expect(tree.isEmpty)
|
||||
}
|
||||
|
||||
@Test func nonEmptyTreeIsNotEmpty() {
|
||||
let view1 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
#expect(!tree.isEmpty)
|
||||
}
|
||||
|
||||
@Test func isNotSplit() {
|
||||
let view1 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
#expect(!tree.isSplit)
|
||||
}
|
||||
|
||||
@Test func isSplit() throws {
|
||||
let (tree, _, _) = try makeHorizontalSplit()
|
||||
#expect(tree.isSplit)
|
||||
}
|
||||
|
||||
// MARK: - Contains and Find
|
||||
|
||||
@Test func treeContainsView() {
|
||||
let view = MockView()
|
||||
let tree = SplitTree<MockView>(view: view)
|
||||
#expect(tree.contains(.leaf(view: view)))
|
||||
}
|
||||
|
||||
@Test func treeDoesNotContainView() {
|
||||
let view = MockView()
|
||||
let tree = SplitTree<MockView>()
|
||||
#expect(!tree.contains(.leaf(view: view)))
|
||||
}
|
||||
|
||||
@Test func findsInsertedView() throws {
|
||||
let (tree, view1, _) = try makeHorizontalSplit()
|
||||
#expect((tree.find(id: view1.id) != nil))
|
||||
}
|
||||
|
||||
@Test func doesNotFindUninsertedView() {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
#expect((tree.find(id: view2.id) == nil))
|
||||
}
|
||||
|
||||
// MARK: - Removing and Replacing
|
||||
|
||||
@Test func treeDoesNotContainRemovedView() throws {
|
||||
var (tree, view1, view2) = try makeHorizontalSplit()
|
||||
tree = tree.removing(.leaf(view: view1))
|
||||
#expect(!tree.contains(.leaf(view: view1)))
|
||||
#expect(tree.contains(.leaf(view: view2)))
|
||||
}
|
||||
|
||||
@Test func removingNonexistentNodeLeavesTreeUnchanged() {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
let result = tree.removing(.leaf(view: view2))
|
||||
#expect(result.contains(.leaf(view: view1)))
|
||||
#expect(!result.isEmpty)
|
||||
}
|
||||
|
||||
@Test func replacingViewShouldRemoveAndInsertView() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let view3 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
#expect(tree.contains(.leaf(view: view2)))
|
||||
let result = try tree.replacing(node: .leaf(view: view2), with: .leaf(view: view3))
|
||||
#expect(result.contains(.leaf(view: view1)))
|
||||
#expect(!result.contains(.leaf(view: view2)))
|
||||
#expect(result.contains(.leaf(view: view3)))
|
||||
}
|
||||
|
||||
@Test func replacingViewWithItselfShouldBeAValidOperation() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
let result = try tree.replacing(node: .leaf(view: view2), with: .leaf(view: view2))
|
||||
#expect(result.contains(.leaf(view: view1)))
|
||||
#expect(result.contains(.leaf(view: view2)))
|
||||
}
|
||||
|
||||
// MARK: - Focus Target
|
||||
|
||||
@Test func focusTargetOnEmptyTreeReturnsNil() {
|
||||
let tree = SplitTree<MockView>()
|
||||
let view = MockView()
|
||||
let target = tree.focusTarget(for: .next, from: .leaf(view: view))
|
||||
#expect(target == nil)
|
||||
}
|
||||
|
||||
@Test func focusTargetShouldFindNextFocusedNode() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
let target = tree.focusTarget(for: .next, from: .leaf(view: view1))
|
||||
#expect(target === view2)
|
||||
}
|
||||
|
||||
@Test func focusTargetShouldFindItselfWhenOnlyView() throws {
|
||||
let view1 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
|
||||
let target = tree.focusTarget(for: .next, from: .leaf(view: view1))
|
||||
#expect(target === view1)
|
||||
}
|
||||
|
||||
// When there's no next view, wraps around to the first
|
||||
@Test func focusTargetShouldHandleWrappingForNextNode() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
let target = tree.focusTarget(for: .next, from: .leaf(view: view2))
|
||||
#expect(target === view1)
|
||||
}
|
||||
|
||||
@Test func focusTargetShouldFindPreviousFocusedNode() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
let target = tree.focusTarget(for: .previous, from: .leaf(view: view2))
|
||||
#expect(target === view1)
|
||||
}
|
||||
|
||||
@Test func focusTargetShouldFindSpatialFocusedNode() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
let target = tree.focusTarget(for: .spatial(.left), from: .leaf(view: view2))
|
||||
#expect(target === view1)
|
||||
}
|
||||
|
||||
// MARK: - Equalized
|
||||
|
||||
@Test func equalizedAdjustsRatioByLeafCount() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let view3 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
tree = try tree.inserting(view: view3, at: view2, direction: .right)
|
||||
|
||||
guard case .split(let before) = tree.root else {
|
||||
Issue.record("unexpected node type")
|
||||
return
|
||||
}
|
||||
#expect(abs(before.ratio - 0.5) < 0.001)
|
||||
|
||||
let equalized = tree.equalized()
|
||||
|
||||
if case .split(let s) = equalized.root {
|
||||
#expect(abs(s.ratio - 1.0/3.0) < 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resizing
|
||||
|
||||
@Test(arguments: [
|
||||
// (resizeDirection, insertDirection, bounds, pixels, expectedRatio)
|
||||
(SplitTree<MockView>.Spatial.Direction.right, SplitTree<MockView>.NewDirection.right,
|
||||
CGRect(x: 0, y: 0, width: 1000, height: 500), UInt16(100), 0.6),
|
||||
(.left, .right,
|
||||
CGRect(x: 0, y: 0, width: 1000, height: 500), UInt16(50), 0.45),
|
||||
(.down, .down,
|
||||
CGRect(x: 0, y: 0, width: 500, height: 1000), UInt16(200), 0.7),
|
||||
(.up, .down,
|
||||
CGRect(x: 0, y: 0, width: 500, height: 1000), UInt16(50), 0.45),
|
||||
])
|
||||
func resizingAdjustsRatio(
|
||||
resizeDirection: SplitTree<MockView>.Spatial.Direction,
|
||||
insertDirection: SplitTree<MockView>.NewDirection,
|
||||
bounds: CGRect,
|
||||
pixels: UInt16,
|
||||
expectedRatio: Double
|
||||
) throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: insertDirection)
|
||||
|
||||
let resized = try tree.resizing(node: .leaf(view: view1), by: pixels, in: resizeDirection, with: bounds)
|
||||
|
||||
guard case .split(let s) = resized.root else {
|
||||
Issue.record("unexpected node type")
|
||||
return
|
||||
}
|
||||
#expect(abs(s.ratio - expectedRatio) < 0.001)
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
@Test func encodingAndDecodingPreservesTree() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
let data = try JSONEncoder().encode(tree)
|
||||
let decoded = try JSONDecoder().decode(SplitTree<MockView>.self, from: data)
|
||||
#expect(decoded.find(id: view1.id) != nil)
|
||||
#expect(decoded.find(id: view2.id) != nil)
|
||||
#expect(decoded.isSplit)
|
||||
}
|
||||
|
||||
@Test func encodingAndDecodingPreservesZoomedPath() throws {
|
||||
let (tree, _, view2) = try makeHorizontalSplit()
|
||||
let treeWithZoomed = SplitTree<MockView>(root: tree.root, zoomed: .leaf(view: view2))
|
||||
|
||||
let data = try JSONEncoder().encode(treeWithZoomed)
|
||||
let decoded = try JSONDecoder().decode(SplitTree<MockView>.self, from: data)
|
||||
|
||||
#expect(decoded.zoomed != nil)
|
||||
if case .leaf(let zoomedView) = decoded.zoomed! {
|
||||
#expect(zoomedView.id == view2.id)
|
||||
} else {
|
||||
Issue.record("unexpected node type")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Collection Conformance
|
||||
|
||||
@Test func treeIteratesLeavesInOrder() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let view3 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
tree = try tree.inserting(view: view3, at: view2, direction: .right)
|
||||
|
||||
#expect(tree.startIndex == 0)
|
||||
#expect(tree.endIndex == 3)
|
||||
#expect(tree.index(after: 0) == 1)
|
||||
|
||||
#expect(tree[0] === view1)
|
||||
#expect(tree[1] === view2)
|
||||
#expect(tree[2] === view3)
|
||||
|
||||
var ids: [UUID] = []
|
||||
for view in tree {
|
||||
ids.append(view.id)
|
||||
}
|
||||
#expect(ids == [view1.id, view2.id, view3.id])
|
||||
}
|
||||
|
||||
@Test func emptyTreeCollectionProperties() {
|
||||
let tree = SplitTree<MockView>()
|
||||
|
||||
#expect(tree.startIndex == 0)
|
||||
#expect(tree.endIndex == 0)
|
||||
|
||||
var count = 0
|
||||
for _ in tree {
|
||||
count += 1
|
||||
}
|
||||
#expect(count == 0)
|
||||
}
|
||||
|
||||
// MARK: - Structural Identity
|
||||
|
||||
@Test func structuralIdentityIsReflexive() throws {
|
||||
let (tree, _, _) = try makeHorizontalSplit()
|
||||
#expect(tree.structuralIdentity == tree.structuralIdentity)
|
||||
}
|
||||
|
||||
@Test func structuralIdentityComparesShapeNotRatio() throws {
|
||||
let (tree, view1, _) = try makeHorizontalSplit()
|
||||
|
||||
let bounds = CGRect(x: 0, y: 0, width: 1000, height: 500)
|
||||
let resized = try tree.resizing(node: .leaf(view: view1), by: 100, in: .right, with: bounds)
|
||||
#expect(tree.structuralIdentity == resized.structuralIdentity)
|
||||
}
|
||||
|
||||
@Test func structuralIdentityForDifferentStructures() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let view3 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
|
||||
let expanded = try tree.inserting(view: view3, at: view2, direction: .down)
|
||||
#expect(tree.structuralIdentity != expanded.structuralIdentity)
|
||||
}
|
||||
|
||||
@Test func structuralIdentityIdentifiesDifferentOrdersShapes() throws {
|
||||
let (tree, _, _) = try makeHorizontalSplit()
|
||||
|
||||
let (otherTree, _, _) = try makeHorizontalSplit()
|
||||
#expect(tree.structuralIdentity != otherTree.structuralIdentity)
|
||||
}
|
||||
|
||||
// MARK: - View Bounds
|
||||
|
||||
@Test func viewBoundsReturnsLeafViewSize() {
|
||||
let view1 = MockView()
|
||||
view1.frame = NSRect(x: 0, y: 0, width: 500, height: 300)
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
|
||||
let bounds = tree.viewBounds()
|
||||
#expect(bounds.width == 500)
|
||||
#expect(bounds.height == 300)
|
||||
}
|
||||
|
||||
@Test func viewBoundsReturnsZeroForEmptyTree() {
|
||||
let tree = SplitTree<MockView>()
|
||||
let bounds = tree.viewBounds()
|
||||
|
||||
#expect(bounds.width == 0)
|
||||
#expect(bounds.height == 0)
|
||||
}
|
||||
|
||||
@Test func viewBoundsHorizontalSplit() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
view1.frame = NSRect(x: 0, y: 0, width: 400, height: 300)
|
||||
view2.frame = NSRect(x: 0, y: 0, width: 200, height: 500)
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
|
||||
let bounds = tree.viewBounds()
|
||||
#expect(bounds.width == 600)
|
||||
#expect(bounds.height == 500)
|
||||
}
|
||||
|
||||
@Test func viewBoundsVerticalSplit() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
view1.frame = NSRect(x: 0, y: 0, width: 300, height: 200)
|
||||
view2.frame = NSRect(x: 0, y: 0, width: 500, height: 400)
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .down)
|
||||
|
||||
let bounds = tree.viewBounds()
|
||||
#expect(bounds.width == 500)
|
||||
#expect(bounds.height == 600)
|
||||
}
|
||||
|
||||
// MARK: - Node
|
||||
|
||||
@Test func nodeFindsLeaf() {
|
||||
let view1 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
|
||||
let node = tree.root?.node(view: view1)
|
||||
#expect(node != nil)
|
||||
#expect(node == .leaf(view: view1))
|
||||
}
|
||||
|
||||
@Test func nodeFindsLeavesInSplitTree() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
|
||||
#expect(tree.root?.node(view: view1) == .leaf(view: view1))
|
||||
#expect(tree.root?.node(view: view2) == .leaf(view: view2))
|
||||
}
|
||||
|
||||
@Test func nodeReturnsNilForMissingView() {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
#expect(tree.root?.node(view: view2) == nil)
|
||||
}
|
||||
|
||||
@Test func resizingUpdatesRatio() throws {
|
||||
let (tree, _, _) = try makeHorizontalSplit()
|
||||
|
||||
guard case .split(let s) = tree.root else {
|
||||
Issue.record("unexpected node type")
|
||||
return
|
||||
}
|
||||
|
||||
let resized = SplitTree<MockView>.Node.split(s).resizing(to: 0.7)
|
||||
guard case .split(let resizedSplit) = resized else {
|
||||
Issue.record("unexpected node type")
|
||||
return
|
||||
}
|
||||
#expect(abs(resizedSplit.ratio - 0.7) < 0.001)
|
||||
}
|
||||
|
||||
@Test func resizingLeavesLeafUnchanged() {
|
||||
let view1 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
|
||||
guard let root = tree.root else {
|
||||
Issue.record("expected non-empty tree")
|
||||
return
|
||||
}
|
||||
let resized = root.resizing(to: 0.7)
|
||||
#expect(resized == root)
|
||||
}
|
||||
|
||||
// MARK: - Spatial
|
||||
|
||||
@Test(arguments: [
|
||||
(SplitTree<MockView>.Spatial.Direction.left, SplitTree<MockView>.NewDirection.right),
|
||||
(.right, .right),
|
||||
(.up, .down),
|
||||
(.down, .down),
|
||||
])
|
||||
func doesBorderEdge(
|
||||
side: SplitTree<MockView>.Spatial.Direction,
|
||||
insertDirection: SplitTree<MockView>.NewDirection
|
||||
) throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: insertDirection)
|
||||
|
||||
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 500))
|
||||
|
||||
// view1 borders left/up; view2 borders right/down
|
||||
let (borderView, nonBorderView): (MockView, MockView) =
|
||||
(side == .right || side == .down) ? (view2, view1) : (view1, view2)
|
||||
#expect(spatial.doesBorder(side: side, from: .leaf(view: borderView)))
|
||||
#expect(!spatial.doesBorder(side: side, from: .leaf(view: nonBorderView)))
|
||||
}
|
||||
|
||||
// MARK: - Calculate View Bounds
|
||||
|
||||
@Test func calculatesViewBoundsForSingleLeaf() {
|
||||
let view1 = MockView()
|
||||
let tree = SplitTree<MockView>(view: view1)
|
||||
|
||||
guard let root = tree.root else {
|
||||
Issue.record("expected non-empty tree")
|
||||
return
|
||||
}
|
||||
|
||||
let bounds = CGRect(x: 0, y: 0, width: 1000, height: 500)
|
||||
let result = root.calculateViewBounds(in: bounds)
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].view === view1)
|
||||
#expect(result[0].bounds == bounds)
|
||||
}
|
||||
|
||||
@Test func calculatesViewBoundsHorizontalSplit() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
|
||||
guard let root = tree.root else {
|
||||
Issue.record("expected non-empty tree")
|
||||
return
|
||||
}
|
||||
|
||||
let bounds = CGRect(x: 0, y: 0, width: 1000, height: 500)
|
||||
let result = root.calculateViewBounds(in: bounds)
|
||||
#expect(result.count == 2)
|
||||
|
||||
let leftBounds = result.first { $0.view === view1 }!.bounds
|
||||
let rightBounds = result.first { $0.view === view2 }!.bounds
|
||||
#expect(leftBounds == CGRect(x: 0, y: 0, width: 500, height: 500))
|
||||
#expect(rightBounds == CGRect(x: 500, y: 0, width: 500, height: 500))
|
||||
}
|
||||
|
||||
@Test func calculatesViewBoundsVerticalSplit() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .down)
|
||||
|
||||
guard let root = tree.root else {
|
||||
Issue.record("expected non-empty tree")
|
||||
return
|
||||
}
|
||||
|
||||
let bounds = CGRect(x: 0, y: 0, width: 500, height: 1000)
|
||||
let result = root.calculateViewBounds(in: bounds)
|
||||
#expect(result.count == 2)
|
||||
|
||||
let topBounds = result.first { $0.view === view1 }!.bounds
|
||||
let bottomBounds = result.first { $0.view === view2 }!.bounds
|
||||
#expect(topBounds == CGRect(x: 0, y: 500, width: 500, height: 500))
|
||||
#expect(bottomBounds == CGRect(x: 0, y: 0, width: 500, height: 500))
|
||||
}
|
||||
|
||||
@Test func calculateViewBoundsCustomRatio() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
|
||||
guard case .split(let s) = tree.root else {
|
||||
Issue.record("unexpected node type")
|
||||
return
|
||||
}
|
||||
|
||||
let resizedRoot = SplitTree<MockView>.Node.split(s).resizing(to: 0.3)
|
||||
let container = CGRect(x: 0, y: 0, width: 1000, height: 400)
|
||||
let result = resizedRoot.calculateViewBounds(in: container)
|
||||
#expect(result.count == 2)
|
||||
|
||||
let leftBounds = result.first { $0.view === view1 }!.bounds
|
||||
let rightBounds = result.first { $0.view === view2 }!.bounds
|
||||
#expect(leftBounds.width == 300) // 0.3 * 1000
|
||||
#expect(rightBounds.width == 700) // 0.7 * 1000
|
||||
#expect(rightBounds.minX == 300)
|
||||
}
|
||||
|
||||
@Test func calculateViewBoundsGrid() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let view3 = MockView()
|
||||
let view4 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
tree = try tree.inserting(view: view3, at: view1, direction: .down)
|
||||
tree = try tree.inserting(view: view4, at: view2, direction: .down)
|
||||
guard let root = tree.root else {
|
||||
Issue.record("expected non-empty tree")
|
||||
return
|
||||
}
|
||||
let container = CGRect(x: 0, y: 0, width: 1000, height: 800)
|
||||
let result = root.calculateViewBounds(in: container)
|
||||
#expect(result.count == 4)
|
||||
|
||||
let b1 = result.first { $0.view === view1 }!.bounds
|
||||
let b2 = result.first { $0.view === view2 }!.bounds
|
||||
let b3 = result.first { $0.view === view3 }!.bounds
|
||||
let b4 = result.first { $0.view === view4 }!.bounds
|
||||
#expect(b1 == CGRect(x: 0, y: 400, width: 500, height: 400)) // top-left
|
||||
#expect(b2 == CGRect(x: 500, y: 400, width: 500, height: 400)) // top-right
|
||||
#expect(b3 == CGRect(x: 0, y: 0, width: 500, height: 400)) // bottom-left
|
||||
#expect(b4 == CGRect(x: 500, y: 0, width: 500, height: 400)) // bottom-right
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(SplitTree<MockView>.Spatial.Direction.right, SplitTree<MockView>.NewDirection.right),
|
||||
(.left, .right),
|
||||
(.down, .down),
|
||||
(.up, .down),
|
||||
])
|
||||
func slotsFromNode(
|
||||
direction: SplitTree<MockView>.Spatial.Direction,
|
||||
insertDirection: SplitTree<MockView>.NewDirection
|
||||
) throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: insertDirection)
|
||||
|
||||
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 500))
|
||||
|
||||
// look from view1 toward view2 for right/down, from view2 toward view1 for left/up
|
||||
let (fromView, expectedView): (MockView, MockView) =
|
||||
(direction == .right || direction == .down) ? (view1, view2) : (view2, view1)
|
||||
let slots = spatial.slots(in: direction, from: .leaf(view: fromView))
|
||||
#expect(slots.count == 1)
|
||||
#expect(slots[0].node == .leaf(view: expectedView))
|
||||
}
|
||||
|
||||
@Test func slotsGridFromTopLeft() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let view3 = MockView()
|
||||
let view4 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
tree = try tree.inserting(view: view3, at: view1, direction: .down)
|
||||
tree = try tree.inserting(view: view4, at: view2, direction: .down)
|
||||
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 800))
|
||||
let rightSlots = spatial.slots(in: .right, from: .leaf(view: view1))
|
||||
let downSlots = spatial.slots(in: .down, from: .leaf(view: view1))
|
||||
// slots() returns both split nodes and leaves; split nodes can tie on distance
|
||||
#expect(rightSlots.contains { $0.node == .leaf(view: view2) })
|
||||
#expect(downSlots.contains { $0.node == .leaf(view: view3) })
|
||||
}
|
||||
|
||||
@Test func slotsGridFromBottomRight() throws {
|
||||
let view1 = MockView()
|
||||
let view2 = MockView()
|
||||
let view3 = MockView()
|
||||
let view4 = MockView()
|
||||
var tree = SplitTree<MockView>(view: view1)
|
||||
tree = try tree.inserting(view: view2, at: view1, direction: .right)
|
||||
tree = try tree.inserting(view: view3, at: view1, direction: .down)
|
||||
tree = try tree.inserting(view: view4, at: view2, direction: .down)
|
||||
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 800))
|
||||
let leftSlots = spatial.slots(in: .left, from: .leaf(view: view4))
|
||||
let upSlots = spatial.slots(in: .up, from: .leaf(view: view4))
|
||||
#expect(leftSlots.contains { $0.node == .leaf(view: view3) })
|
||||
#expect(upSlots.contains { $0.node == .leaf(view: view2) })
|
||||
}
|
||||
|
||||
@Test func slotsReturnsEmptyWhenNoNodesInDirection() throws {
|
||||
let (tree, view1, view2) = try makeHorizontalSplit()
|
||||
|
||||
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 500))
|
||||
#expect(spatial.slots(in: .left, from: .leaf(view: view1)).isEmpty)
|
||||
#expect(spatial.slots(in: .right, from: .leaf(view: view2)).isEmpty)
|
||||
#expect(spatial.slots(in: .up, from: .leaf(view: view1)).isEmpty)
|
||||
#expect(spatial.slots(in: .down, from: .leaf(view: view2)).isEmpty)
|
||||
}
|
||||
|
||||
// Set/Dictionary usage is the only path that exercises StructuralIdentity.hash(into:)
|
||||
@Test func structuralIdentityHashableBehavior() throws {
|
||||
let (tree, _, _) = try makeHorizontalSplit()
|
||||
let id = tree.structuralIdentity
|
||||
|
||||
#expect(id == id)
|
||||
|
||||
var seen: Set<SplitTree<MockView>.StructuralIdentity> = []
|
||||
seen.insert(id)
|
||||
seen.insert(id)
|
||||
#expect(seen.count == 1)
|
||||
|
||||
var cache: [SplitTree<MockView>.StructuralIdentity: String] = [:]
|
||||
cache[id] = "two-pane"
|
||||
#expect(cache[id] == "two-pane")
|
||||
}
|
||||
|
||||
@Test func nodeStructuralIdentityInSet() throws {
|
||||
let (tree, _, _) = try makeHorizontalSplit()
|
||||
|
||||
guard case .split(let s) = tree.root else {
|
||||
Issue.record("unexpected node type")
|
||||
return
|
||||
}
|
||||
|
||||
var nodeIds: Set<SplitTree<MockView>.Node.StructuralIdentity> = []
|
||||
nodeIds.insert(tree.root!.structuralIdentity)
|
||||
nodeIds.insert(s.left.structuralIdentity)
|
||||
nodeIds.insert(s.right.structuralIdentity)
|
||||
#expect(nodeIds.count == 3)
|
||||
}
|
||||
|
||||
@Test func nodeStructuralIdentityDistinguishesLeaves() throws {
|
||||
let (tree, _, _) = try makeHorizontalSplit()
|
||||
|
||||
guard case .split(let s) = tree.root else {
|
||||
Issue.record("unexpected node type")
|
||||
return
|
||||
}
|
||||
|
||||
var nodeIds: Set<SplitTree<MockView>.Node.StructuralIdentity> = []
|
||||
nodeIds.insert(s.left.structuralIdentity)
|
||||
nodeIds.insert(s.right.structuralIdentity)
|
||||
#expect(nodeIds.count == 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user