diff --git a/Catchlight/UI/Components/UIKitEditor/BlockEditorViewController.swift b/Catchlight/UI/Components/UIKitEditor/BlockEditorViewController.swift index 71b1b62..32a769a 100644 --- a/Catchlight/UI/Components/UIKitEditor/BlockEditorViewController.swift +++ b/Catchlight/UI/Components/UIKitEditor/BlockEditorViewController.swift @@ -416,6 +416,24 @@ final class BlockEditorViewController: UIViewController, UITextViewDelegate { // MARK: - Drag-to-reorder + /// The slot the dragged row should occupy: the number of OTHER rows whose centre is still + /// above the dragged row's centre, clamped to the end. + /// + /// Extracted from `updateDrag` so the maths is testable at all — the retired SwiftUI editor + /// had pure `rowCenters`/`reorderTarget` statics with unit tests, and when that editor was + /// deleted at M7 the tests went with it, leaving reorder with NO coverage. This is the same + /// decision the old `reorderTarget` made (by REAL row centres, so a drag over wrapped + /// multi-line rows lands where the finger is — a fixed row height under/overshot tall rows, + /// owner 2026-06-21); only the surrounding mechanics differ. + /// + /// `prefix(while:)` and NOT `filter(_:).count` — deliberately. It counts the LEADING run of + /// rows above the drag, i.e. it STOPS at the first row below it. That's equivalent to a + /// filter-count only because the stack's rows are in visual order (ascending centres); if a + /// caller ever passes unsorted centres, prefix-while is the behaviour that was shipped. + nonisolated static func reorderTarget(draggedCenterY: CGFloat, otherCenterYs: [CGFloat]) -> Int { + min(otherCenterYs.prefix { $0 < draggedCenterY }.count, otherCenterYs.count) + } + @objc private func reorderPan(_ g: UIPanGestureRecognizer) { guard let id = reorderRowID[g], let row = rowContainers[id] else { return } switch g.state { @@ -452,9 +470,9 @@ final class BlockEditorViewController: UIViewController, UITextViewDelegate { let y = gesture.location(in: scrollView).y row.frame.origin.y += (y - dragLastY) dragLastY = y - let centerY = row.frame.midY let others = stack.arrangedSubviews.filter { $0 !== placeholder } - let target = min(others.prefix { $0.frame.midY < centerY }.count, others.count) + let target = Self.reorderTarget(draggedCenterY: row.frame.midY, + otherCenterYs: others.map(\.frame.midY)) if stack.arrangedSubviews.firstIndex(of: placeholder) != target { UIView.animate(withDuration: 0.16) { self.stack.insertArrangedSubview(placeholder, at: target) diff --git a/Tests/CatchlightCoreTests/BlockEditorReorderGeometryTests.swift b/Tests/CatchlightCoreTests/BlockEditorReorderGeometryTests.swift new file mode 100644 index 0000000..9acb624 --- /dev/null +++ b/Tests/CatchlightCoreTests/BlockEditorReorderGeometryTests.swift @@ -0,0 +1,108 @@ +// +// BlockEditorReorderGeometryTests.swift +// CatchlightCoreTests — the UIKit editor's checklist-reorder maths +// +// Replaces `InlineReorderGeometryTests`, which tested the RETIRED SwiftUI editor's +// `rowCenters`/`reorderTarget` statics and was deleted with it at M7 (PR #130) — leaving reorder +// with no coverage at all. The UIKit editor makes the same decision by REAL row centres (so a drag +// over wrapped multi-line rows lands where the finger is; a fixed row height under/overshot tall +// rows — owner 2026-06-21), just from live view frames rather than measured heights. The pure part +// is `BlockEditorViewController.reorderTarget`; the drag mechanics are on-device (the simulator +// doesn't deliver synthesized drags reliably — see `BlockEditorUITests.testReorder_dragHandle`). +// +// App-target only, matching the file it replaces. +// + +#if canImport(Catchlight) +import XCTest +import CoreGraphics +@testable import Catchlight + +final class BlockEditorReorderGeometryTests: XCTestCase { + + /// Rows of equal height 44 + 2 spacing → centres at 22, 68, 114, 160. + private let evenRows: [CGFloat] = [22, 68, 114, 160] + + // MARK: - Resting + + func testTarget_draggedAboveEveryRow_isFirstSlot() { + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 0, + otherCenterYs: evenRows), 0) + } + + func testTarget_draggedBelowEveryRow_clampsToLastSlot() { + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 1000, + otherCenterYs: evenRows), 4) + } + + // MARK: - Stepping through slots + + func testTarget_stepsSlotAsTheDragPassesEachCentre() { + // Just past the first centre → slot 1; past the second → slot 2; and so on. + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 23, + otherCenterYs: evenRows), 1) + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 69, + otherCenterYs: evenRows), 2) + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 115, + otherCenterYs: evenRows), 3) + } + + func testTarget_exactlyOnACentre_doesNotYetPassIt() { + // Strictly-less-than: sitting exactly on a centre leaves the row above it, so a + // resting row can't jitter between two slots. + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 68, + otherCenterYs: evenRows), 1) + } + + // MARK: - No-op: released in the origin slot + + func testTarget_releasedInOriginSlot_leavesOrderUnchanged() { + // Lift the row at index 2 and let go without moving it. `otherCenterYs` is every OTHER + // row (the dragged one is excluded), so the two rows originally above it stay above and + // the target comes back as 2 — the same slot, i.e. no reorder. This is the common case: + // a stray touch on the drag handle must not shuffle the list. + let others: [CGFloat] = [22, 68, 160, 206] // rows 0,1,3,4 — index 2 (centre 114) is dragged + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 114, + otherCenterYs: others), 2) + } + + // MARK: - The reason this maths exists: TALL rows + + func testTarget_tallWrappedRow_usesItsRealCentreNotAFixedHeight() { + // A wrapped multi-line row: heights 44 / 120 / 44 → centres 22, 106, 188. + // Dragging to y=100 is still ABOVE the tall row's real centre (106), so the finger + // has not passed it — slot 1. A fixed 44pt row height would have called this slot 2 + // and overshot, which is the bug this replaced (owner 2026-06-21). + let tall: [CGFloat] = [22, 106, 188] + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 100, + otherCenterYs: tall), 1) + // Past the tall row's centre → slot 2. + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 110, + otherCenterYs: tall), 2) + } + + // MARK: - Degenerate inputs + + func testTarget_noOtherRows_isAlwaysSlotZero() { + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 50, + otherCenterYs: []), 0) + } + + func testTarget_singleOtherRow_bothSides() { + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 10, + otherCenterYs: [22]), 0) + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 30, + otherCenterYs: [22]), 1) + } + + /// `prefix(while:)`, not `filter(_:).count` — it stops at the FIRST row below the drag. + /// The two agree only while centres ascend, which they do for a real stack. Pinned here so + /// nobody "simplifies" it into a filter-count and silently changes behaviour on odd input. + func testTarget_stopsAtTheFirstRowBelow_ratherThanCountingAllRowsAbove() { + // Deliberately unsorted: 22 is above the drag, 300 is below, 40 is above again. + // prefix-while stops at 300 → 1. A filter-count would say 2. + XCTAssertEqual(BlockEditorViewController.reorderTarget(draggedCenterY: 50, + otherCenterYs: [22, 300, 40]), 1) + } +} +#endif