| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import SwiftUI |
| 4 | import XCTest |
| 5 | import RastrilloApple |
| 6 | @testable import SheetsKit |
| 7 | |
| 8 | final class SheetNativeInteractionTests: XCTestCase { |
| 9 | @MainActor func testTypingEntersCellAndFailedCommitKeepsDraft() async throws { |
| 10 | let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://127.0.0.1:1")!)) |
| 11 | model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil) |
| 12 | let host = NSHostingView(rootView: SheetGrid(model: model).background(Color.white).preferredColorScheme(.light)) |
| 13 | let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], backing: .buffered, defer: false) |
| 14 | window.contentView = host |
| 15 | NSApp.setActivationPolicy(.regular) |
| 16 | window.makeKeyAndOrderFront(nil) |
| 17 | host.layoutSubtreeIfNeeded() |
| 18 | window.displayIfNeeded() |
| 19 | NSApp.activate() |
| 20 | defer { window.orderOut(nil) } |
| 21 | try await Task.sleep(for: .milliseconds(300)) |
| 22 | for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { |
| 23 | let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 350), modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)! |
| 24 | window.sendEvent(event) |
| 25 | } |
| 26 | try await Task.sleep(for: .milliseconds(100)) |
| 27 | key("h", code: 4, window: window) |
| 28 | try await Task.sleep(for: .milliseconds(150)) |
| 29 | XCTAssertEqual(model.draft, "h") |
| 30 | XCTAssertTrue(window.firstResponder is NSTextView, "Typing must transfer focus into a native cell text field") |
| 31 | key("e", code: 14, window: window) |
| 32 | try await Task.sleep(for: .milliseconds(100)) |
| 33 | XCTAssertEqual(model.draft, "he", "The initial character must not be selected and overwritten") |
| 34 | if ProcessInfo.processInfo.environment["OFICINA_CAPTURE_FIXTURES"] == "1", |
| 35 | let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) { |
| 36 | host.cacheDisplay(in: host.bounds, to: bitmap) |
| 37 | try bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: "/tmp/oficina-sheets-grid-fixture.png")) |
| 38 | } |
| 39 | key("\t", code: 48, window: window) |
| 40 | try await Task.sleep(for: .milliseconds(500)) |
| 41 | XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 0)) |
| 42 | XCTAssertEqual(model.draft, "he") |
| 43 | XCTAssertTrue(model.hasUnconfirmedChanges) |
| 44 | XCTAssertFalse(model.failedWrites.isEmpty) |
| 45 | XCTAssertTrue(window.firstResponder is NSTextView) |
| 46 | key("\u{1b}", code: 53, window: window) |
| 47 | try await Task.sleep(for: .milliseconds(100)) |
| 48 | XCTAssertFalse(model.hasUnconfirmedChanges) |
| 49 | XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 0)) |
| 50 | // Column B header then row 2 header select complete ranges. |
| 51 | click(NSPoint(x: 200, y: 375), window: window) |
| 52 | try await Task.sleep(for: .milliseconds(100)) |
| 53 | XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 1)) |
| 54 | XCTAssertEqual(model.selection.end, Coordinate(r: 3, c: 1)) |
| 55 | click(NSPoint(x: 24, y: 320), window: window) |
| 56 | try await Task.sleep(for: .milliseconds(100)) |
| 57 | XCTAssertEqual(model.selection.start, Coordinate(r: 1, c: 0)) |
| 58 | XCTAssertEqual(model.selection.end, Coordinate(r: 1, c: 2)) |
| 59 | } |
| 60 | @MainActor func testRapidTypingDuringFocusTransfer() async throws { |
| 61 | for delay in [0, 1, 5, 10, 25, 50] { |
| 62 | let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://127.0.0.1:1")!)) |
| 63 | model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil) |
| 64 | let host = NSHostingView(rootView: SheetGrid(model: model)) |
| 65 | let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], backing: .buffered, defer: false) |
| 66 | window.contentView = host; NSApp.setActivationPolicy(.regular); window.makeKeyAndOrderFront(nil); NSApp.activate() |
| 67 | defer { window.orderOut(nil) } |
| 68 | try await Task.sleep(for: .milliseconds(200)) |
| 69 | click(NSPoint(x: 100, y: 350), window: window) |
| 70 | try await Task.sleep(for: .milliseconds(100)) |
| 71 | key("h", code: 4, window: window) |
| 72 | if delay > 0 { try await Task.sleep(for: .milliseconds(delay)) } |
| 73 | key("e", code: 14, window: window) |
| 74 | try await Task.sleep(for: .milliseconds(200)) |
| 75 | XCTAssertEqual(model.draft, "he", "Typing during focus handoff (\(delay)ms)") |
| 76 | } |
| 77 | } |
| 78 | func testResizePreviewUsesOriginalSizeAndServerBounds() { |
| 79 | var preview = SheetResizePreview(axis: "col", index: 0, original: 110) |
| 80 | preview.update(translation: 30) |
| 81 | XCTAssertEqual(preview.size, 140) |
| 82 | preview.update(translation: 40) |
| 83 | XCTAssertEqual(preview.size, 150) |
| 84 | preview.update(translation: -1000) |
| 85 | XCTAssertEqual(preview.size, 16) |
| 86 | preview.update(translation: 5000) |
| 87 | XCTAssertEqual(preview.size, 2000) |
| 88 | } |
| 89 | @MainActor func testDragResizeWaitsForReleaseAndRollsBackOnFailure() async throws { |
| 90 | for axis in ["col", "row"] { |
| 91 | for succeeds in [false, true] { |
| 92 | let server = try ResizeFixtureServer() |
| 93 | let origin = try await server.start() |
| 94 | defer { server.stop() } |
| 95 | let model = SheetModel(client: NativeHTTP(origin: succeeds ? origin : URL(string: "http://127.0.0.1:1")!)) |
| 96 | model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil) |
| 97 | let host = NSHostingView(rootView: SheetGrid(model: model)) |
| 98 | let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], backing: .buffered, defer: false) |
| 99 | window.contentView = host; NSApp.setActivationPolicy(.regular); window.makeKeyAndOrderFront(nil); NSApp.activate() |
| 100 | defer { window.orderOut(nil) } |
| 101 | try await Task.sleep(for: .milliseconds(200)) |
| 102 | func handles(_ view: NSView) -> [SheetResizeHandle.Handle] { |
| 103 | (view as? SheetResizeHandle.Handle).map { [$0] } ?? view.subviews.flatMap(handles) |
| 104 | } |
| 105 | let handle = try XCTUnwrap(handles(host).first { $0.actions?.vertical == (axis == "row") }) |
| 106 | let start = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil) |
| 107 | let end = NSPoint(x: start.x + (axis == "col" ? 40 : 0), y: start.y - (axis == "row" ? 40 : 0)) |
| 108 | func send(_ type: NSEvent.EventType, _ point: NSPoint) { |
| 109 | window.sendEvent(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!) |
| 110 | } |
| 111 | // Cancel one drag first: no save, and geometry must return to its original position. |
| 112 | send(.leftMouseDown, start) |
| 113 | send(.leftMouseDragged, end) |
| 114 | try await Task.sleep(for: .milliseconds(100)) |
| 115 | let preview = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil) |
| 116 | XCTAssertEqual(axis == "col" ? preview.x - start.x : start.y - preview.y, 40, accuracy: 1) |
| 117 | handle.keyDown(with: NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)!) |
| 118 | send(.leftMouseUp, end) |
| 119 | try await Task.sleep(for: .milliseconds(100)) |
| 120 | XCTAssertEqual(server.requests.count, 0) |
| 121 | XCTAssertEqual(handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil), start) |
| 122 | send(.leftMouseDown, start) |
| 123 | try await Task.sleep(for: .milliseconds(50)) |
| 124 | for step in 1...5 { |
| 125 | send(.leftMouseDragged, NSPoint(x: start.x + (end.x - start.x) * Double(step) / 5, y: start.y + (end.y - start.y) * Double(step) / 5)) |
| 126 | try await Task.sleep(for: .milliseconds(30)) |
| 127 | } |
| 128 | try await Task.sleep(for: .milliseconds(150)) |
| 129 | XCTAssertFalse(model.busy) |
| 130 | XCTAssertEqual(server.requests.count, 0) |
| 131 | XCTAssertEqual(model.error, "", "Dragging must not send intermediate requests") |
| 132 | send(.leftMouseUp, end) |
| 133 | try await Task.sleep(for: .milliseconds(300)) |
| 134 | if succeeds { |
| 135 | XCTAssertEqual(model.error, "") |
| 136 | XCTAssertEqual(server.requests, [[axis: 0, "size": axis == "col" ? 150 : 72]]) |
| 137 | XCTAssertEqual(axis == "col" ? model.sizes.cols?["0"] : model.sizes.rows?["0"], axis == "col" ? 150 : 72) |
| 138 | } else { |
| 139 | XCTAssertFalse(model.error.isEmpty, "Releasing the \(axis) handle must attempt persistence") |
| 140 | XCTAssertNil(model.sizes.cols?["0"]) |
| 141 | XCTAssertNil(model.sizes.rows?["0"]) |
| 142 | XCTAssertEqual(handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil), start) |
| 143 | } |
| 144 | XCTAssertFalse(model.busy) |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | @MainActor private func click(_ point: NSPoint, window: NSWindow) { |
| 149 | for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { |
| 150 | let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)! |
| 151 | window.sendEvent(event) |
| 152 | } |
| 153 | } |
| 154 | @MainActor private func key(_ text: String, code: UInt16, window: NSWindow) { |
| 155 | let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: text, charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)! |
| 156 | window.sendEvent(event) |
| 157 | } |
| 158 | } |
| 159 | #endif |
| 160 | |