diff --git a/macos/Tests/Splits/SplitTreeTests.swift b/macos/Tests/Splits/SplitTreeTests.swift new file mode 100644 index 000000000..5ef84b8ec --- /dev/null +++ b/macos/Tests/Splits/SplitTreeTests.swift @@ -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) { + let view1 = MockView() + let view2 = MockView() + var tree = SplitTree(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() + #expect(tree.isEmpty) + } + + @Test func nonEmptyTreeIsNotEmpty() { + let view1 = MockView() + let tree = SplitTree(view: view1) + #expect(!tree.isEmpty) + } + + @Test func isNotSplit() { + let view1 = MockView() + let tree = SplitTree(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(view: view) + #expect(tree.contains(.leaf(view: view))) + } + + @Test func treeDoesNotContainView() { + let view = MockView() + let tree = SplitTree() + #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(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(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(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() + 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(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(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.Spatial.Direction.right, SplitTree.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.Spatial.Direction, + insertDirection: SplitTree.NewDirection, + bounds: CGRect, + pixels: UInt16, + expectedRatio: Double + ) throws { + let view1 = MockView() + let view2 = MockView() + var tree = SplitTree(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.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(root: tree.root, zoomed: .leaf(view: view2)) + + let data = try JSONEncoder().encode(treeWithZoomed) + let decoded = try JSONDecoder().decode(SplitTree.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(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() + + #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(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(view: view1) + + let bounds = tree.viewBounds() + #expect(bounds.width == 500) + #expect(bounds.height == 300) + } + + @Test func viewBoundsReturnsZeroForEmptyTree() { + let tree = SplitTree() + 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(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(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(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(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.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(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.Spatial.Direction.left, SplitTree.NewDirection.right), + (.right, .right), + (.up, .down), + (.down, .down), + ]) + func doesBorderEdge( + side: SplitTree.Spatial.Direction, + insertDirection: SplitTree.NewDirection + ) throws { + let view1 = MockView() + let view2 = MockView() + var tree = SplitTree(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(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(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.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(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.Spatial.Direction.right, SplitTree.NewDirection.right), + (.left, .right), + (.down, .down), + (.up, .down), + ]) + func slotsFromNode( + direction: SplitTree.Spatial.Direction, + insertDirection: SplitTree.NewDirection + ) throws { + let view1 = MockView() + let view2 = MockView() + var tree = SplitTree(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(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(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.StructuralIdentity> = [] + seen.insert(id) + seen.insert(id) + #expect(seen.count == 1) + + var cache: [SplitTree.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.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.Node.StructuralIdentity> = [] + nodeIds.insert(s.left.structuralIdentity) + nodeIds.insert(s.right.structuralIdentity) + #expect(nodeIds.count == 2) + } +}