krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Tests/OrgCoreTests/TableFormulaTests.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Tests/OrgCoreTests/TableFormulaTests.swift history · blame · raw

170 lines · 13007 bytes

  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5struct TableFormulaTests {
  6    static let tables = [
  7        "| a | b |\n|---+---|\n| 1 |   |\n| 2 |   |\n| 3 |   |\n#+TBLFM: $2=$1*2\n",
  8        "| x | y | sum |\n|---+---+-----|\n| 1 | 2 |     |\n| 3.5 | 4 | |\n|---+---+-----|\n|   |   |     |\n#+TBLFM: $3=$1+$2::@>$1=vsum(@I..@II)::@>$2=vmean(@2$2..@3$2)\n",
  9        "| qty | price | total | share |\n|-----+-------+-------+-------|\n| 3 | 19.99 | | |\n| 1 | 5 | | |\n| 12 | 0.5 | | |\n|-----+-------+-------+-------|\n| | | | |\n#+TBLFM: $3=$1*$2;%.2f::$4=$3/@>$3;f3::@>$3=vsum(@I..@II)\n",
 10        "| n | sq | cube | root |\n|---+----+------+------|\n| 1 | | | |\n| 2 | | | |\n| 10 | | | |\n#+TBLFM: $2=$1^2::$3=$1^3::$4=sqrt($1)\n",
 11        "| a | b |\n| 1 | |\n| 2 | |\n| 7 | |\n#+TBLFM: $2=$1/2\n",
 12        "| a | prev | next |\n|---+------+------|\n| 1 | | |\n| 2 | | |\n| 3 | | |\n#+TBLFM: $2=@-1$1::$3=@+1$1\n",
 13        "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n| 4 | 5 | |\n#+TBLFM: $3=$-1*$-2::@2$3=100\n",
 14        "| r | v |\n|---+---|\n| 1 | |\n| 2 | |\n| 3 | |\n#+TBLFM: @2$2..@4$2=$1*10\n",
 15        "| r | v |\n|---+---|\n| 1 | |\n| 2 | |\n#+TBLFM: $>=@#*$#\n",
 16        "| a | b | c |\n|---+---+---|\n| 1 |   | |\n|   | 2 | |\n#+TBLFM: $3=$1+$2\n",
 17        "| a | b |\n|---+---|\n| 5 | |\n| 6 | |\n#+TBLFM: $2=$1*3\n#+TBLFM: $2=$1+100\n",
 18        "| a | b |\n|---+---|\n| 1 |\n| 2 | |\n#+TBLFM: $3=$1*$1\n",
 19        "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n| 3 | 4 | |\n#+TBLFM: $3=vsum($1..$2)::@2=7\n",
 20        "| a | b |\n|---+---|\n| 1.25 | |\n| 0.1 | |\n| 1e3 | |\n| -2 | |\n#+TBLFM: $2=$1/7\n",
 21        "| deg | sin | cos |\n|-----+-----+-----|\n| 0 | | |\n| 30 | | |\n| 45 | | |\n| 90 | | |\n#+TBLFM: $2=sin($1)::$3=cos($1);%.3f\n",
 22        "  | a | b |\n  |---+---|\n  | 2 |   |\n  | 3 |   |\n  #+TBLFM: $2=$1^0.5;n4\n",
 23        "| a | b |\n|---+---|\n| x | |\n| 2 | |\n#+TBLFM: $2=$1*2\n",
 24        "| a | b |\n|---+---|\n| 1 | |\n|---+---|\n| 2 | |\n| 3 | |\n#+TBLFM: $2=@I$1+@II$1+@-I$1\n",
 25        "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n#+TBLFM: $3=($1+$2)*2/3-1::$1=$1\n",
 26        "| a |\n|---|\n| 1 |\n| 2 |\n|---|\n| |\n#+TBLFM: @>$1=vmax(@I..@II)-vmin(@I..@II)+vcount(@I..@II)+vmedian(@I..@II)\n",
 27        "#+CONSTANTS: c=10 k=2.5\n| a | b |\n|---+---|\n| 1 | |\n| 2 | |\n#+TBLFM: $2=$1*$c+$k\n",
 28        "* Rates\n:PROPERTIES:\n:Rate:     3\n:END:\n| h | pay |\n|---+-----|\n| 2 | |\n#+TBLFM: $2=$1*$PROP_Rate\n",
 29    ]
 30
 31    /// Lisp formulas, names, parameters, marked rows, remote tables, durations, dates and
 32    /// the rest that once needed Emacs: all run natively.
 33    static let native = [
 34        "| a | b |\n|---+---|\n| hello | |\n| x \"q\" | |\n|  | |\n#+TBLFM: $2='(concat $1 \"!\")\n",
 35        "| a | b |\n|---+---|\n| 1 | |\n| 2.5 | |\n| x | |\n#+TBLFM: $2='(* 2 $1);N\n",
 36        "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n| 3 | 4 | |\n#+TBLFM: $3='(+ $1 $2);L\n",
 37        "| a |\n|---|\n| 1 |\n| 2 |\n|---|\n| |\n#+TBLFM: @>$1='(apply '+ '(@I..@II));N\n",
 38        "| a | b |\n|---+---|\n| 1 | |\n#+TBLFM: $2='(car 1)\n",
 39        "| a | b | c |\n|---+---+---|\n| 3.14159 | | |\n| 2 | | |\n#+TBLFM: $2='(format \"%05.1f\" $1);N::$3='(+ $1 1);N%.2f\n",
 40        "| a | b |\n|---+---|\n| 5 | |\n| 12 | |\n#+TBLFM: $2='(if (> $1 10) \"big\" \"small\");N\n",
 41        "| ! | qty | price | total |\n|---+-----+-------+-------|\n|   | 2 | 3 | |\n|   | 4 | 0.5 | |\n#+TBLFM: $4=$qty*$price\n",
 42        "|   | x | y |\n|---+---+---|\n| # | 2 |   |\n|   | 3 |   |\n| * | 4 |   |\n| $ | k=10 | |\n#+TBLFM: $3=$2*$k\n",
 43        "|   | a  | b  |\n|---+----+----|\n|   | 1  | 2  |\n| ^ | aa | bb |\n| _ |    | cc |\n|   |    |    |\n#+TBLFM: $cc=$aa+$bb::$2=7\n",
 44        "|   | a  | b  |\n|---+----+----|\n|   | 1  | 2  |\n| _ |    | dd |\n#+TBLFM: $dd=1\n",
 45        "| start | end | sum | diff | hours |\n|-------+-----+-----+------+-------|\n| 1:30 | 0:45 | | | |\n| 10:00:30 | 2:15:10 | | | |\n|-------+-----+-----+------+-------|\n| | | | | |\n#+TBLFM: $3=$1+$2;T::$4=$1-$2;U::$5=$1*2;t::@>$1=vsum(@I..@II);T\n",
 46        "| d1 | d2 | days | later |\n|----+----+------+-------|\n| <2026-10-05 Mon> | <2026-10-12 Mon> | | |\n| [2026-01-31 Sat] | [2026-03-01 Sun] | | |\n| <2026-10-05 Mon 08:30> | <2026-10-05 Mon 10:00> | | |\n#+TBLFM: $3=$2-$1::$4=$1+7\n",
 47        "| d | until |\n|---+-------|\n| <2026-10-05 Mon> | |\n#+TBLFM: $2=<2026-12-25 Fri>-$1\n",
 48        "| a | b | c | d |\n|---+---+---+---|\n| 1 |   | | |\n| 2 | 3 | | |\n#+TBLFM: $3=$1+$2;E::$4=vcount($1..$2);E\n",
 49        "| a | b | c |\n|---+---+---|\n| 5 | 3 | |\n| 2 | 9 | |\n#+TBLFM: $3=if($1>$2, $1, $2)\n",
 50        "| a | b | c |\n|---+---+---|\n| 7 | 3 | |\n| -7 | 2 | |\n#+TBLFM: $3=$1%$2+$1\\$2*100\n",
 51        "#+NAME: rates\n| item | rate |\n|------+------|\n| a    | 2    |\n| b    | 3    |\n\n| x | y | z | w |\n|---+---+---+---|\n| 4 | | | |\n#+TBLFM: $2=$1*remote(rates,@2$2)::$3=remote(rates, B3)::$4=vsum(remote(rates,@2$2..@>$2))\n",
 52        "#+NAME: prices\n| fruit | price |\n|-------+-------|\n| apple | 1.5 |\n| pear | 2 |\n\n| item | cost |\n|------+------|\n| pear | |\n| plum | |\n#+TBLFM: $2='(org-lookup-first $1 '(remote(prices,@2$1..@>$1)) '(remote(prices,@2$2..@>$2)))\n",
 53        "#+CONSTANTS: c=3\n| a | b | c |\n|---+---+---|\n| 2 | | |\n#+TBLFM: $2='(* $c $1);N::$3=$1*$c\n",
 54        "|   | a | b |\n|---+---+---|\n|   | 2 | |\n|   | 3 | |\n| $ | %=%.2f | |\n#+TBLFM: $3=$2/3;\n",
 55        "| a | b |\n|---+---|\n| 4 | |\n| 9 | |\n#+TBLFM: $2=vsdev(@I$1..@II$1)+fact(3)\n",
 56        "| a | b |\n|---+---|\n| 1 |   |\n| 2 |   |\n#+TBLFM: @-1$2=5\n",
 57        "| a | b | f |\n|---+---+---|\n| 1 | 3 |  |\n| 7 | 2 |  |\n| -7 | 2 |  |\n| 6 | 3 |  |\n| 1 | 0.5 |  |\n| 4 | 6 |  |\n#+TBLFM: $3=$1/$2;F\n",
 58        "| a |\n|---|\n|  |\n|  |\n|  |\n|  |\n|  |\n|  |\n|  |\n|  |\n|  |\n#+TBLFM: @2$1=1/3+1/6;F::@3$1=(1/3)^2;F::@4$1=vsum([1/2,1/3]);F::@5$1=1/3*3;F::@6$1=sqrt(1/4);F::@7$1=vmean([1,2]);F::@8$1=2^-2;F::@9$1=abs(-1/3)+max(1/3,1/4);F::@10$1=round(7/2)+floor(7/2);F\n",
 59        "| 10 | 20 | |\n| 1 | 2 | |\n| 3 | 4 | |\n#+TBLFM: $3=@0$1+@0$2*10\n",
 60        "#+NAME: t1\n| 5 | 6 |\n| 1 | 2 |\n\n#+NAME: t2\n| 7 | 8 |\n\n| name | val | sum |\n|------+-----+-----|\n| t1 | | |\n| t2 | | |\n#+TBLFM: $2=remote($1,@1$1)::$3=vsum(remote(t1,$1..$2))+vsum($1..$2)\n",
 61    ]
 62
 63    @Test func remoteFindsTablesByIDInOtherFiles() {
 64        let other = "* Elsewhere\n:PROPERTIES:\n:ID: far\n:END:\n| v |\n|---|\n| 7 |\n"
 65        RemoteTables.fileWithID = { $0 == "far" ? other : nil }
 66        defer { RemoteTables.fileWithID = nil }
 67        let text = "| x | y |\n|---+---|\n| 3 | |\n#+TBLFM: $2=$1*remote(far,@2$1)\n"
 68        let result = runCommand(TableRecalculate(all: true), text, caret: (text as NSString).range(of: "#+TBLFM").location + 3)
 69        #expect(result.text.contains("| 3 | 21 |"), "\(result)")
 70        // As Emacs does in a file buffer (`org-id-find` needs one): the entry's first table.
 71        let local = "* Rates\n:PROPERTIES:\n:ID: rates-id\n:END:\n| item | rate |\n|------+------|\n| a    | 2    |\n* Use\n| x | y |\n|---+---|\n| 4 | |\n#+TBLFM: $2=$1*remote(rates-id,@2$2)\n"
 72        #expect(runCommand(TableRecalculate(all: true), local, caret: (local as NSString).range(of: "#+TBLFM").location + 3).text.contains("| 4 | 8 |"))
 73        let missing = runCommand(TableRecalculate(all: true), text.replacingOccurrences(of: "far", with: "nowhere"), caret: 2)
 74        #expect(missing.failure == "Can't find remote table \"nowhere\"")
 75    }
 76
 77    static func carets(_ text: String) -> [Int] {
 78        let ns = text as NSString
 79        var result: [Int] = []
 80        var location = 0
 81        while location < ns.length {
 82            let line = ns.lineRange(for: NSRange(location: location, length: 0))
 83            let content = ns.substring(with: line)
 84            if let bar = content.firstIndex(of: "|"), !content.contains("|-") {
 85                // Inside the first field of the row.
 86                result.append(line.location + content.distance(from: content.startIndex, to: bar) + 2)
 87            }
 88            if content.uppercased().contains("#+TBLFM") { result.append(line.location + 3) }
 89            location = NSMaxRange(line)
 90        }
 91        return result
 92    }
 93
 94    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
 95    func matchesEmacs() throws {
 96        try compare(Self.tables, fallbacksAllowed: true)
 97    }
 98
 99    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
100    func extendedFormulasMatchEmacsNatively() throws {
101        try compare(Self.native, fallbacksAllowed: false)
102    }
103
104    func compare(_ tables: [String], fallbacksAllowed: Bool) throws {
105        var cases: [EmacsOracle.Case] = []
106        var ours: [(text: String, caret: Int, failure: String?)] = []
107        for text in tables {
108            for caret in Self.carets(text) {
109                let onTblfm = (text as NSString).substring(with: (text as NSString).lineRange(for: NSRange(location: caret, length: 0))).uppercased().contains("#+TBLFM")
110                let commands: [(any OrgCommand, String)] = onTblfm
111                    ? [(TableRecalculate(all: true), "(org-ctrl-c-ctrl-c)")]
112                    : [(TableRecalculate(all: true), "(org-table-recalculate t)"), (TableRecalculate(all: false), "(org-table-recalculate nil)")]
113                for (command, form) in commands {
114                    cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: form))
115                    ours.append(runCommand(command, text, caret: caret))
116                }
117            }
118        }
119        let emacs = try EmacsOracle.run(cases)
120        var fallbacks = 0
121        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
122            if let failure = mine.failure, failure.hasPrefix("external:") {
123                fallbacks += 1
124                // Calc keeps text as symbols, which goes to Emacs.
125                #expect(fallbacksAllowed || failure.contains("non-numeric field"), "\(failure) in\n\(cases[i].text)")
126                // Emacs gives symbolic results for text and division by zero.
127                #expect(failure.contains("non-numeric field") || failure.contains("division by zero"), "\(failure)")
128                continue
129            }
130            if !theirs.error.isEmpty {
131                // Emacs keeps the fields written before the error; nothing changes here.
132                #expect(mine.failure == theirs.error && mine.text == cases[i].text,
133                        "\(cases[i].form) at \(cases[i].point): ours \(mine.failure ?? "-"), emacs \(theirs.error)\n\(cases[i].text)")
134                continue
135            }
136            let expected = (theirs.text, EmacsOracle.offset(theirs.point, in: theirs.text), theirs.error.isEmpty ? nil : theirs.error)
137            #expect(mine.text == expected.0 && mine.caret == expected.1 && mine.failure == expected.2,
138                    "\(cases[i].form) at \(cases[i].point) in\n\(cases[i].text)\nours:\n\(mine.text)@\(mine.caret) \(mine.failure ?? "")\nemacs:\n\(theirs.text)@\(expected.1) \(theirs.error)")
139        }
140        #expect(fallbacks < ours.count / 2, "\(fallbacks) fallbacks")
141    }
142
143    /// `ORGSTAR_ORACLE_CORPUS=<folder>`: C-c C-c on every `#+TBLFM` line below the folder.
144    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
145    func corpusMatchesEmacs() throws {
146        let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
147        let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
148            .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
149        var cases: [EmacsOracle.Case] = []
150        var ours: [(text: String, caret: Int, failure: String?)] = []
151        for file in files {
152            guard let text = try? String(contentsOf: file, encoding: .utf8), text.uppercased().contains("#+TBLFM") else { continue }
153            let ns = text as NSString
154            var location = 0
155            while location < ns.length {
156                let line = ns.lineRange(for: NSRange(location: location, length: 0))
157                if ns.substring(with: line).trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("#+TBLFM") {
158                    cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(line.location, in: text), form: "(org-ctrl-c-ctrl-c)"))
159                    ours.append(runCommand(TableRecalculate(all: true), text, caret: line.location))
160                }
161                location = NSMaxRange(line)
162            }
163        }
164        let emacs = try EmacsOracle.run(cases)
165        for (mine, theirs) in zip(ours, emacs) where !(mine.failure ?? "").hasPrefix("external:") {
166            #expect(mine.text == theirs.text, "\(mine.failure ?? "")")
167        }
168        print("TBLFM lines: \(cases.count), native: \(ours.filter { !($0.failure ?? "").hasPrefix("external:") }.count)")
169    }
170}