Tests/OrgCoreTests/TableFormulaTests.swift
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}