| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import XCTest |
| 4 | @testable import DocsKit |
| 5 | |
| 6 | @MainActor final class DocumentPageEditingTests: XCTestCase { |
| 7 | func testOptimisticAndMarkedEditsInvalidatePageOffsets() throws { |
| 8 | let nodes = (0..<100).map { index in |
| 9 | CRDTNode(id: "p\(index)", type: "paragraph", children: [CRDTNode(id: "t\(index)", type: "text", runs: [CRDTRun(insert: "A paragraph kept together when pages are recalculated.")])]) |
| 10 | } |
| 11 | let view = DocsPagedTextView(frame: CGRect(x: 0, y: 0, width: 636, height: 800)) |
| 12 | let storage = try XCTUnwrap(view.textStorage) |
| 13 | storage.setAttributedString(NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false))) |
| 14 | view.paged = true |
| 15 | view.layout() |
| 16 | let container = try XCTUnwrap(view.textContainer as? DocsPageTextContainer) |
| 17 | XCTAssertFalse(container.blockStarts.isEmpty) |
| 18 | let initial = container.blockStarts |
| 19 | let insertion = String(repeating: "Inserted text before the boundary. ", count: 20) |
| 20 | storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: insertion) |
| 21 | assertBlockStarts(container, storage) |
| 22 | view.layout() |
| 23 | XCTAssertNotEqual(initial, container.blockStarts) |
| 24 | assertBlockStarts(container, storage) |
| 25 | view.setSelectedRange(NSRange(location: 0, length: 0)) |
| 26 | view.setMarkedText("日本語", selectedRange: NSRange(location: 3, length: 0), replacementRange: NSRange(location: 0, length: 0)) |
| 27 | assertBlockStarts(container, storage) |
| 28 | view.layout() |
| 29 | assertBlockStarts(container, storage) |
| 30 | view.unmarkText() |
| 31 | storage.replaceCharacters(in: NSRange(location: 0, length: 3 + insertion.utf16.count), with: "") |
| 32 | assertBlockStarts(container, storage) |
| 33 | view.layout() |
| 34 | XCTAssertEqual(container.blockStarts, initial) |
| 35 | XCTAssertEqual(storage.string, nodes.map(\.plainText).joined(separator: "\n") + "\n") |
| 36 | } |
| 37 | |
| 38 | func testMetadataReconciliationNeverExposesUnpaginatedCaretGeometry() throws { |
| 39 | let nodes = (0..<1000).map { index in |
| 40 | CRDTNode(id: "p\(index)", type: "paragraph", children: [CRDTNode(id: "t\(index)", type: "text", runs: [CRDTRun(insert: "A paragraph kept together when pages are recalculated.")])]) |
| 41 | } |
| 42 | let view = DocsPagedTextView(frame: CGRect(x: 0, y: 0, width: 636, height: 800)) |
| 43 | let storage = try XCTUnwrap(view.textStorage) |
| 44 | storage.setAttributedString(NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false))) |
| 45 | view.paged = true |
| 46 | view.layout() |
| 47 | let manager = try XCTUnwrap(view.layoutManager) |
| 48 | let container = try XCTUnwrap(view.textContainer as? DocsPageTextContainer) |
| 49 | XCTAssertGreaterThanOrEqual(container.blockStarts.count, 2) |
| 50 | let selected = storage.length - 3 |
| 51 | view.setSelectedRange(NSRange(location: selected, length: 0)) |
| 52 | func caretY() -> CGFloat { |
| 53 | manager.ensureLayout(for: container) |
| 54 | return manager.lineFragmentRect(forGlyphAt: manager.glyphIndexForCharacter(at: selected), effectiveRange: nil).minY |
| 55 | } |
| 56 | let before = caretY() |
| 57 | let start = ContinuousClock.now |
| 58 | for generation in 0..<10 { |
| 59 | storage.addAttribute(NSAttributedString.Key("MetadataOnly"), value: generation, range: NSRange(location: 0, length: storage.length)) |
| 60 | XCTAssertEqual(caretY(), before, accuracy: 0.01, "AppKit can query the caret before the next view layout") |
| 61 | } |
| 62 | print("Ten metadata updates in a 1,000-paragraph document:", start.duration(to: .now)) |
| 63 | view.layout() |
| 64 | XCTAssertEqual(caretY(), before, accuracy: 0.01) |
| 65 | XCTAssertEqual(view.selectedRange(), NSRange(location: selected, length: 0)) |
| 66 | } |
| 67 | |
| 68 | func testCaretBackgroundErasureStaysInsideRequestedRectangle() throws { |
| 69 | let view = DocsPagedTextView(frame: CGRect(x: 0, y: 0, width: 636, height: 800)) |
| 70 | view.layout() |
| 71 | let bitmap = try XCTUnwrap(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 100, |
| 72 | bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, |
| 73 | colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) |
| 74 | let context = try XCTUnwrap(NSGraphicsContext(bitmapImageRep: bitmap)) |
| 75 | NSGraphicsContext.saveGraphicsState() |
| 76 | defer { NSGraphicsContext.restoreGraphicsState() } |
| 77 | NSGraphicsContext.current = context |
| 78 | NSColor.red.setFill() |
| 79 | CGRect(x: 0, y: 0, width: 100, height: 100).fill() |
| 80 | view.drawBackground(in: CGRect(x: 40, y: 40, width: 20, height: 20)) |
| 81 | XCTAssertEqual(try XCTUnwrap(bitmap.colorAt(x: 20, y: 20)).greenComponent, 0, accuracy: 0.01, |
| 82 | "Erasing one caret must not paint over neighboring glyphs") |
| 83 | XCTAssertGreaterThan(try XCTUnwrap(bitmap.colorAt(x: 50, y: 50)).greenComponent, 0.9) |
| 84 | } |
| 85 | |
| 86 | func testTypingKeepsCaretInsidePageBoundsWithStableOrigin() throws { |
| 87 | let nodes = (0..<60).map { index in |
| 88 | CRDTNode(id: "p\(index)", type: "paragraph", children: [CRDTNode(id: "t\(index)", type: "text", runs: [CRDTRun(insert: String(repeating: "A paragraph that wraps across lines. ", count: 8))])]) |
| 89 | } |
| 90 | let view = DocsTextView(frame: CGRect(x: 0, y: 0, width: 900, height: 800)) |
| 91 | view.textStorage!.setAttributedString(NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false))) |
| 92 | view.paged = true |
| 93 | view.layout() |
| 94 | view.setSelectedRange(NSRange(location: view.string.utf16.count - 1, length: 0)) |
| 95 | for text in ["\n", "a", "\n", "b"] { |
| 96 | view.insertText(text, replacementRange: view.selectedRange()) |
| 97 | view.layoutManager!.ensureLayout(for: view.textContainer!) |
| 98 | let glyph = view.layoutManager!.glyphIndexForCharacter(at: view.selectedRange().location) |
| 99 | let line = view.layoutManager!.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) |
| 100 | XCTAssertEqual(view.textContainerOrigin, .zero) |
| 101 | XCTAssertGreaterThanOrEqual(view.bounds.maxY, line.maxY) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | private func assertBlockStarts(_ container: DocsPageTextContainer, _ storage: NSTextStorage) { |
| 106 | for location in container.blockStarts.keys { |
| 107 | var range = NSRange() |
| 108 | XCTAssertNotNil(storage.attribute(.docsTopBlock, at: location, effectiveRange: &range)) |
| 109 | XCTAssertEqual(range.location, location, "A separator must never move into a paragraph") |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | #endif |
| 114 | |