Tests/OrgCoreTests/CalcTests.swift
114 lines · 7157 bytes
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Runs `calc-eval` with org's table modes over a list of expressions.
6enum CalcOracle {
7 static func run(_ cases: [(expression: String, format: String)]) throws -> [String] {
8 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-calc-\(UUID().uuidString)")
9 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
10 defer { try? FileManager.default.removeItem(at: folder) }
11 let input = folder.appendingPathComponent("input.json")
12 let output = folder.appendingPathComponent("output.json")
13 let script = folder.appendingPathComponent("calc.el")
14 try JSONSerialization.data(withJSONObject: cases.map { [$0.expression, $0.format] }).write(to: input)
15 try #"""
16 ;;; -*- lexical-binding: t -*-
17 (require 'calc)
18 (require 'org-table)
19 (require 'json)
20 (let* ((cases (with-temp-buffer
21 (insert-file-contents (getenv "ORACLE_INPUT"))
22 (json-parse-buffer :array-type 'list)))
23 (results
24 (mapcar (lambda (c)
25 (let* ((modes (copy-sequence org-calc-default-modes))
26 (format (car (read-from-string (nth 1 c)))))
27 (setq modes (plist-put modes 'calc-float-format format))
28 (let ((r (calc-eval (cons (car c) modes) nil)))
29 (if (stringp r) r (format "ERROR %S" r)))))
30 cases)))
31 (with-temp-file (getenv "ORACLE_OUTPUT")
32 (insert (json-encode (vconcat results)))))
33 """#.write(to: script, atomically: true, encoding: .utf8)
34 _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path])
35 return try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
36 }
37}
38
39struct CalcTests {
40 static let fixed = [
41 "1+2", "7/2", "6/3", "1/3", "2/3", "(2)^(0.5)", "sqrt(2)", "sqrt(4)", "sqrt(16.0)", "1.5*2", "3.0", "(3.0)", "2^-1",
42 "10/4", "1e5", "1e20*1", "123456789*1000", "0.1+0.2", "1/7", "100/7", "1000000/7", "1e10/7", "1e-5/7", "0.001/7",
43 "0.0001/3", "(1.23456789012345)", "vsum([1,2,3.5])", "vmean([1,2])", "vmean([1,2,4])", "vmax([1,5,3])", "vsum([])",
44 "vcount([1,2])", "vmedian([1,3,2,4])", "sin(30)", "cos(60)", "tan(45)", "exp(1)", "ln(10)", "log10(1000)", "log10(2)",
45 "-3", "(-3)*2", "--3", "(+3)", "2*(0)", "2^0.5*2^0.5", "99999999.5*1", "123456785*0.1", "0.5*1e-10", "1.5e3",
46 "12345678.9*1", "123456789.1*1", "1.25*1", "0.000123*1", "1e100*1e100", "round(2.5)", "floor(2.7)", "abs(-3)",
47 "max(1,2)", "1.05*3", "2.675*1", "8/2*2", "8/2/2", "2*3/4", "-2^2", "2^3^2", "2^-2", "(-2)^2", "sin(0)", "sin(90)",
48 "cos(0)", "cos(90)", "tan(0)", "exp(0)", "ln(1)", "log10(1)", "log10(100.0)", "sqrt(2.25)", "sqrt(0)", "arcsin(1)",
49 "arctan(1)", "arccos(0.5)", "1.5^2", "1.1^10", "0.1^3", "2.5*4", "10/2.5", "1/3*3", "1.0/3", "vmedian([1,3])",
50 "vmedian([1,2,3])", "vmedian([1.5,2])", "vmean([1.5,2.5])", "vsum([0.1,0.2,0.3])", "vmax([1,2.5])", "vmin([3,1.0])",
51 "round(-2.5)", "round(2.4)", "round(1.23456,2)", "floor(-2.5)", "ceil(2.1)", "trunc(-2.7)", "abs(-1.5)", "max(1,2.5)",
52 "min(4,2)", "1e3", "1.e3", "007", "(0.5)", "(.5)", "(5.)", "123456789012*10", "1.23456789012345678", "exp(10)",
53 "exp(100)", "ln(0.5)", "sqrt(1e-10)", "2.0^2", "0^0", "1-1.0", "0.0", "-0.0", "1.0-1.0", "(-1.5)*(-2)",
54 "(12)+(3.5)*(2)", "vsum([12,3.5,7])/3", "(1.5)/(0.5)", "(100)*(1.07)^(5)", "(19.99)*(3)", "(0.07)*(19.99)",
55 "vmean([10,20,25])", "(5)-(7.25)", "(-0.001)*(1000)", "1/(3)", "(2)/(3)*(3)",
56 // Dates, nan, comparisons and logic, and the rest of the functions tables use.
57 "<2026-10-05 Mon>+3", "<2026-10-12 Mon>-<2026-10-05 Mon>", "<2026-10-05 Mon 10:00>-<2026-10-05 Mon 08:30>",
58 "<2026-10-05 Mon 08:30>+0.5", "<2026-10-05 Mon 10:00>+1", "<2026-10-05 Mon 08:30>-<2026-10-04 Sun>", "(<2026-12-31 Thu>)+(1)",
59 "<2026-03-01 Sun>-30", "1+<2026-10-05 Mon>", "<2026-10-05 Mon 23:59>-<2026-10-05 Mon 00:01>", "<2026-10-05 Mon 12:00>-0.25",
60 "vmax([<2026-10-05 Mon>,<2026-10-07 Wed>])", "<2026-10-05 Mon> < <2026-10-06 Tue>", "nan", "nan+1", "vsum([1,nan])", "2*nan",
61 "3<4", "3>4", "3<=3", "4>=5", "2==2", "2!=2", "1 && 0", "1 || 0", "!0", "!2", "if(3>4, 1, 2)", "if($1, 7, 8)".replacingOccurrences(of: "$1", with: "0"),
62 "if(1.5, 2.5, 3)", "7%3", "-7%3", "7%-3", "7.5%2", "7\\2", "-7\\2", "7.5\\2", "mod(10,4)", "idiv(-9,4)", "fact(5)", "fact(0)",
63 "vsdev([1,2,3,4])", "vvar([1,2,3])", "vpvar([1,2,3,4])", "vpsdev([2,4,4,4,5,5,7,9])", "vsdev([1.5,2.5,4])",
64 "2+3<4*2", "1<2 && 3<4", "!(1<2)", "(1<2)+(2<3)",
65 ]
66
67 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
68 func matchesCalc() throws {
69 var cases = Self.fixed.map { ($0, "(float 8)") }
70 for format in ["(float 3)", "(fix 2)", "(fix 0)", "(sci 3)", "(eng 2)"] {
71 for e in ["2/3", "3", "1.5", "1234.5678", "0.000123", "1e20*1", "-2/3", "1/7*100"] { cases.append((e, format)) }
72 }
73 // Generated: random operands and operators.
74 var generator = SystemRandomNumberGenerator()
75 let numbers = ["1", "2", "3", "7", "10", "0.5", "1.25", "3.14159", "100", "0.001", "12.5", "99", "0.3", "1e3", "2.5e-2"]
76 let ops = ["+", "-", "*", "/"]
77 for _ in 0..<300 {
78 var e = numbers.randomElement(using: &generator)!
79 for _ in 0..<Int.random(in: 1...4, using: &generator) {
80 e = "(\(e))\(ops.randomElement(using: &generator)!)(\(numbers.randomElement(using: &generator)!))"
81 }
82 cases.append((e, "(float 8)"))
83 }
84 let emacs = try CalcOracle.run(cases)
85 #expect(emacs.count == cases.count)
86 var skipped: [String] = []
87 for (c, expected) in zip(cases, emacs) {
88 var modes = Calc.Modes()
89 let f = c.1.dropFirst().dropLast().split(separator: " ")
90 let n = Int(f[1])!
91 switch f[0] {
92 case "fix": modes.format = .fix(n)
93 case "sci": modes.format = .sci(n)
94 case "eng": modes.format = .eng(n)
95 default: modes.format = .float(n)
96 }
97 let ours: String
98 do { ours = try Calc.evaluate(c.0, modes: modes) } catch { ours = "unsupported: \(error)" }
99 if ours.hasPrefix("unsupported") {
100 skipped.append(c.0)
101 continue
102 }
103 #expect(ours == expected, "\(c.0) \(c.1)")
104 }
105 // Only what Calc itself can't show as a number.
106 #expect(skipped == [], "\(skipped)")
107 }
108
109 @Test func unsupported() {
110 for e in ["abc*2", "1/0", "vmean([])", "pi", "5%", "(-8)^(1/3)", "2 3", "vvar([5])", "1<2<3"] {
111 #expect(throws: Calc.Unsupported.self, "\(e)") { try Calc.evaluate(e) }
112 }
113 }
114}