|
| 1 | +import AppKit |
| 2 | +import XCTest |
| 3 | +@testable import BlockInputKit |
| 4 | + |
| 5 | +extension BlockInputViewNarrowResizeTests { |
| 6 | + func testWrappedRowsStaySeparatedAfterOpeningScrollingAndResizing() async throws { |
| 7 | + for scrollerStyle in [NSScroller.Style.legacy, .overlay] { |
| 8 | + let view = BlockInputView(frame: NSRect(x: 0, y: 0, width: 572, height: 480)) |
| 9 | + view.scrollView.scrollerStyle = scrollerStyle |
| 10 | + view.configure(BlockInputConfiguration(document: Self.reflowDocument, allowsBlockReordering: false)) |
| 11 | + let window = NSWindow(contentRect: view.frame, styleMask: [.titled], backing: .buffered, defer: false) |
| 12 | + window.contentView = view |
| 13 | + defer { window.contentView = nil } |
| 14 | + |
| 15 | + for width in [CGFloat(572), 672, 572] { |
| 16 | + if window.contentView?.bounds.width != width { |
| 17 | + window.setContentSize(NSSize(width: width, height: 480)) |
| 18 | + } |
| 19 | + await settleReflow(view, window: window) |
| 20 | + let maximumOffset = max(0, view.collectionView.frame.height - view.scrollView.contentView.bounds.height) |
| 21 | + let offsets = Array(stride(from: 0.0, through: maximumOffset, by: 120)) + [maximumOffset, 0] |
| 22 | + for offset in offsets { |
| 23 | + view.scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset)) |
| 24 | + view.scrollView.reflectScrolledClipView(view.scrollView.contentView) |
| 25 | + await settleReflow(view, window: window) |
| 26 | + try assertVisibleRowsFit(view, context: "width \(width), scroller \(scrollerStyle), offset \(offset)") |
| 27 | + } |
| 28 | + } |
| 29 | + } |
| 30 | + } |
| 31 | + |
| 32 | + func testWidthReflowPreservesMountedSelectionAndScrollOffset() async throws { |
| 33 | + let mounted = makeMountedBlockInputView(configuration: BlockInputConfiguration( |
| 34 | + document: Self.reflowDocument, |
| 35 | + allowsBlockReordering: false |
| 36 | + ), size: NSSize(width: 572, height: 480)) |
| 37 | + defer { mounted.window.contentView = nil } |
| 38 | + await settleReflow(mounted.view, window: mounted.window) |
| 39 | + mounted.view.scrollView.contentView.scroll(to: NSPoint(x: 0, y: 80)) |
| 40 | + await settleReflow(mounted.view, window: mounted.window) |
| 41 | + let item = try XCTUnwrap(mounted.view.collectionView.item(at: IndexPath(item: 2, section: 0)) as? BlockInputBlockItem) |
| 42 | + let selection = NSRange(location: 5, length: 8) |
| 43 | + XCTAssertTrue(mounted.window.makeFirstResponder(item.textView)) |
| 44 | + item.textView.setSelectedRange(selection) |
| 45 | + let offset = mounted.view.scrollView.contentView.bounds.origin |
| 46 | + |
| 47 | + // Only the final width should reach the deferred reconciliation. |
| 48 | + mounted.window.setContentSize(NSSize(width: 672, height: 480)) |
| 49 | + mounted.view.layoutSubtreeIfNeeded() |
| 50 | + mounted.window.setContentSize(NSSize(width: 620, height: 480)) |
| 51 | + await settleReflow(mounted.view, window: mounted.window) |
| 52 | + |
| 53 | + XCTAssertTrue(mounted.view.collectionView.item(at: IndexPath(item: 2, section: 0)) === item) |
| 54 | + XCTAssertTrue(mounted.window.firstResponder === item.textView) |
| 55 | + XCTAssertEqual(item.textView.selectedRange(), selection) |
| 56 | + XCTAssertEqual(mounted.view.scrollView.contentView.bounds.origin, offset) |
| 57 | + try assertVisibleRowsFit(mounted.view, context: "Coalesced width changes") |
| 58 | + } |
| 59 | + |
| 60 | + private func settleReflow(_ view: BlockInputView, window: NSWindow) async { |
| 61 | + // Width reconciliation can schedule another pass when a legacy scrollbar appears. |
| 62 | + for _ in 0..<4 { |
| 63 | + view.layoutSubtreeIfNeeded() |
| 64 | + window.displayIfNeeded() |
| 65 | + await withCheckedContinuation { continuation in |
| 66 | + DispatchQueue.main.async { continuation.resume() } |
| 67 | + } |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + private func assertVisibleRowsFit(_ view: BlockInputView, context: String) throws { |
| 72 | + let items = view.collectionView.visibleItems().compactMap { $0 as? BlockInputBlockItem }.sorted { |
| 73 | + $0.view.frame.minY < $1.view.frame.minY |
| 74 | + } |
| 75 | + XCTAssertFalse(items.isEmpty, context) |
| 76 | + for item in items { |
| 77 | + let indexPath = try XCTUnwrap(view.collectionView.indexPath(for: item)) |
| 78 | + let attributes = try XCTUnwrap(view.layout.layoutAttributesForItem(at: indexPath)) |
| 79 | + XCTAssertEqual(attributes.frame.width, item.view.frame.width, accuracy: 0.5, context) |
| 80 | + let block = try XCTUnwrap(item.renderedBlock) |
| 81 | + let textContainer = try XCTUnwrap(item.textView.textContainer) |
| 82 | + let layoutManager = try XCTUnwrap(item.textView.layoutManager) |
| 83 | + layoutManager.ensureLayout(for: textContainer) |
| 84 | + let metrics = BlockInputBlockItem.verticalMetrics(for: block) |
| 85 | + let renderedHeight = ceil(layoutManager.usedRect(for: textContainer).maxY) |
| 86 | + + metrics.topContentInset + metrics.bottomContentInset |
| 87 | + XCTAssertLessThanOrEqual(renderedHeight, item.view.frame.height + 0.5, context) |
| 88 | + } |
| 89 | + for (previous, next) in zip(items, items.dropFirst()) { |
| 90 | + XCTAssertLessThanOrEqual(previous.view.frame.maxY, next.view.frame.minY + 0.5, context) |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + private static var reflowDocument: BlockInputDocument { |
| 95 | + let bullets = [ |
| 96 | + "Check correctness, security, performance, readability, and maintainability. Comment on the changed lines, " |
| 97 | + + "not pre-existing code, unless the change breaks it.", |
| 98 | + "Only include actionable findings that point at a specific problem or concrete suggestion, framed as a question " |
| 99 | + + "where that reads naturally. No praise, no \"looks good\" filler: a comment that is not actionable is omitted " |
| 100 | + + "entirely, not softened.", |
| 101 | + "Decide deliberately whether each minor finding earns a comment; note in your reply any you considered and " |
| 102 | + + "left out rather than dropping them silently." |
| 103 | + ] |
| 104 | + return BlockInputDocument(blocks: (0..<6).flatMap { section in |
| 105 | + [BlockInputBlock(kind: .heading(level: 2), text: "Section \(section)")] |
| 106 | + + bullets.map { BlockInputBlock(kind: .bulletedListItem, text: $0) } |
| 107 | + }) |
| 108 | + } |
| 109 | +} |
0 commit comments