| 1 | import Foundation |
| 2 | import RastrilloApple |
| 3 | import Testing |
| 4 | |
| 5 | @testable import SheetsKit |
| 6 | |
| 7 | @Test func coordinatesAndReverseRanges() { |
| 8 | #expect(Coordinate.parse("aa151") == Coordinate(r: 150, c: 26)) |
| 9 | #expect(Coordinate.parse("A0") == nil) |
| 10 | #expect(Coordinate.parse("A1junk") == nil) |
| 11 | #expect(CellRange.parse("B3:A1")?.count == 6) |
| 12 | #expect(CellRange.parse("A1:") == nil) |
| 13 | } |
| 14 | @Test func spreadsheetClipboardRoundTrip() { |
| 15 | let values = [["a\tb", "line\nnext", "say \"hello\""], ["", "=A1+$B$2", ""]] |
| 16 | #expect(TSV.decode(TSV.encode(values)) == values) |
| 17 | #expect(TSV.decode("a\tb\r\nc\td\r\n") == [["a", "b"], ["c", "d"]]) |
| 18 | } |
| 19 | |
| 20 | @MainActor @Test func fillCopiesAnchorWithFormulaOriginAndBounds() { |
| 21 | let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!)) |
| 22 | model.cells[Coordinate(r: 0, c: 0)] = Cell(r: 0, c: 0, raw: "=B1", display: "0") |
| 23 | let down = model.fillWrites(down: true) |
| 24 | #expect(down.count == 1) |
| 25 | #expect(down.first?.r == 1 && down.first?.c == 0) |
| 26 | #expect(down.first?.raw == "=B1") |
| 27 | #expect(down.first?.fromR == 0 && down.first?.fromC == 0) |
| 28 | let right = model.fillWrites(down: false) |
| 29 | #expect(right.first?.r == 0 && right.first?.c == 1) |
| 30 | model.selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 1, c: 1)) |
| 31 | #expect(model.fillWrites(down: true).count == 3) |
| 32 | #expect( |
| 33 | model.fillWrites(down: true).allSatisfy { $0.raw == "=B1" && $0.fromR == 0 && $0.fromC == 0 }) |
| 34 | let end = Coordinate(r: model.rows - 1, c: model.cols - 1) |
| 35 | model.selection = CellRange(end, end) |
| 36 | #expect(model.fillWrites(down: true).isEmpty) |
| 37 | #expect(model.fillWrites(down: false).isEmpty) |
| 38 | model.copied = [down] |
| 39 | model.copiedText = "=B1" |
| 40 | model.clearCopyProvenance() |
| 41 | #expect(model.copied == nil && model.copiedText.isEmpty) |
| 42 | } |
| 43 | @Test func streamedEventsRespectBoundaries() async { |
| 44 | let parser = SheetEventParser() |
| 45 | #expect(await parser.receive("event: cells") == nil) |
| 46 | #expect(await parser.receive("data: {\"version\":1}") == nil) |
| 47 | let event = await parser.receive("") |
| 48 | #expect(event?.name == "cells") |
| 49 | #expect(event?.data == "{\"version\":1}") |
| 50 | } |
| 51 | |
| 52 | @MainActor @Test func formulaDraftTracksSelectionSynchronously() { |
| 53 | let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!)) |
| 54 | let first = Coordinate(r: 0, c: 0) |
| 55 | model.cells[first] = Cell(r: 0, c: 0, raw: "=2+3", display: "5") |
| 56 | model.select(first) |
| 57 | #expect(model.draft == "=2+3") |
| 58 | #expect(!model.dirty) |
| 59 | model.draft = "=7*6" |
| 60 | model.select(Coordinate(r: 1, c: 0)) |
| 61 | #expect(model.selection.start == first) |
| 62 | #expect(model.draft == "=7*6") |
| 63 | model.discard() |
| 64 | #expect(model.draft == "=2+3") |
| 65 | #expect(!model.dirty) |
| 66 | model.select(Coordinate(r: 1, c: 0)) |
| 67 | #expect(model.draft.isEmpty) |
| 68 | #expect(!model.dirty) |
| 69 | } |
| 70 | |
| 71 | @MainActor @Test func olderLiveResponseCannotReplaceCurrentCell() async { |
| 72 | let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!)) |
| 73 | model.version = 2 |
| 74 | model.cells[Coordinate(r: 0, c: 0)] = Cell(r: 0, c: 0, raw: "new", display: "new") |
| 75 | await model.accept( |
| 76 | GridResponse(version: 1, cells: [Cell(r: 0, c: 0, raw: "old", display: "old")])) |
| 77 | #expect(model.cells[Coordinate(r: 0, c: 0)]?.raw == "new") |
| 78 | } |
| 79 | |
| 80 | @MainActor @Test func peerEditsUpdateCleanFormulaButPreserveDraft() async { |
| 81 | let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!)) |
| 82 | let first = Coordinate(r: 0, c: 0) |
| 83 | model.cells[first] = Cell(r: 0, c: 0, raw: "=2+3", display: "5") |
| 84 | model.select(first) |
| 85 | await model.accept(GridResponse(version: 1, cells: [Cell(r: 0, c: 0, raw: "=3+4", display: "7")])) |
| 86 | #expect(model.draft == "=3+4") |
| 87 | #expect(!model.dirty) |
| 88 | model.draft = "my pending edit" |
| 89 | await model.accept(GridResponse(version: 2, cells: [Cell(r: 0, c: 0, raw: "=4+5", display: "9")])) |
| 90 | #expect(model.cells[first]?.display == "9") |
| 91 | #expect(model.draft == "my pending edit") |
| 92 | #expect(model.dirty) |
| 93 | } |
| 94 | |
| 95 | @MainActor @Test func pendingBatchCannotBeReplacedByFormulaCommit() async { |
| 96 | let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!)) |
| 97 | model.failedWrites = [ |
| 98 | CellWrite(r: 1, c: 0, raw: "first pending"), CellWrite(r: 2, c: 0, raw: "second pending"), |
| 99 | ] |
| 100 | model.draft = "a new edit" |
| 101 | await model.commit() |
| 102 | #expect(model.failedWrites.map(\.raw) == ["first pending", "second pending"]) |
| 103 | #expect(model.dirty) |
| 104 | #expect(model.error.contains("Retry or discard")) |
| 105 | model.discard() |
| 106 | model.rangeText = "B2:C3" |
| 107 | model.selectRange() |
| 108 | #expect(model.selection.start == Coordinate(r: 0, c: 0)) |
| 109 | #expect(model.failedWrites.count == 2) |
| 110 | } |
| 111 | |
| 112 | @Test func workbookExportNamesUseSafeDocumentTitles() { |
| 113 | let url = URL(fileURLWithPath: "/tmp/private-staging.xlsx") |
| 114 | #expect(WorkbookExport(url: url, title: "Budget 2026").filename == "Budget 2026.xlsx") |
| 115 | #expect(WorkbookExport(url: url, title: "../Budget:2026\n").filename == "-Budget-2026.xlsx") |
| 116 | #expect(WorkbookExport(url: url, title: "...").filename == "Workbook.xlsx") |
| 117 | #expect(WorkbookExport(url: url, title: String(repeating: "đŸ§ª", count: 100)).filename.utf8.count <= 205) |
| 118 | } |
| 119 | |