Commit cacf40a484
Verified · cmc
Layout: unified · split
Sources/OrgCore/Time/ReadDate.swift added +297
| @@ -0,0 +1,297 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Org's date prompt (`org-read-date-analyze`, Org 9.8.7) and the `parse-time-string` it uses, | ||
| 4 | /// with `org-read-date-prefer-future` t. `now` and `defaultDate` stand in for the clock. | ||
| 5 | public enum ReadDate { | ||
| 6 | public struct Answer: Sendable, Equatable { | ||
| 7 | public var year: Int, month: Int, day: Int, hour: Int, minute: Int | ||
| 8 | /// The answer included a time (`org-time-was-given`). | ||
| 9 | public var timeGiven: Bool | ||
| 10 | /// An end time such as `11:00`, from `10:00-11:00` (`org-end-time-was-given`). | ||
| 11 | public var endTime: String? | ||
| 12 | |||
| 13 | /// Normalized like `encode-time` (day 32 becomes the next month). | ||
| 14 | public func components(_ calendar: Calendar) -> DateComponents { | ||
| 15 | var components = DateComponents(year: year, month: month, day: 1, hour: 0, minute: 0) | ||
| 16 | let base = calendar.date(from: components)! | ||
| 17 | let date = calendar.date(byAdding: DateComponents(month: 0, day: day - 1, hour: hour, minute: minute), to: base)! | ||
| 18 | components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .weekday], from: date) | ||
| 19 | return components | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | static let weekdays: [(String, Int)] = [ | ||
| 24 | ("sun", 0), ("mon", 1), ("tue", 2), ("wed", 3), ("thu", 4), ("fri", 5), ("sat", 6), | ||
| 25 | ("sunday", 0), ("monday", 1), ("tuesday", 2), ("wednesday", 3), ("thursday", 4), ("friday", 5), ("saturday", 6), | ||
| 26 | ] | ||
| 27 | static let months: [(String, Int)] = [ | ||
| 28 | ("jan", 1), ("feb", 2), ("mar", 3), ("apr", 4), ("may", 5), ("jun", 6), ("jul", 7), ("aug", 8), ("sep", 9), | ||
| 29 | ("oct", 10), ("nov", 11), ("dec", 12), ("january", 1), ("february", 2), ("march", 3), ("april", 4), ("june", 6), | ||
| 30 | ("july", 7), ("august", 8), ("september", 9), ("october", 10), ("november", 11), ("december", 12), | ||
| 31 | ] | ||
| 32 | |||
| 33 | enum Token: Equatable { | ||
| 34 | case number(Int) | ||
| 35 | case word(String) | ||
| 36 | } | ||
| 37 | |||
| 38 | /// `parse-time-tokenize` on the downcased string. | ||
| 39 | static func tokenize(_ string: String) -> [Token] { | ||
| 40 | let chars = Array(string.lowercased().unicodeScalars) | ||
| 41 | func kind(_ c: Unicode.Scalar) -> Character? { | ||
| 42 | switch c { | ||
| 43 | case "a"..."z": "a" | ||
| 44 | case "0"..."9": "0" | ||
| 45 | case "+", "-", ":": Character(c) | ||
| 46 | default: nil | ||
| 47 | } | ||
| 48 | } | ||
| 49 | var tokens: [Token] = [] | ||
| 50 | var index = 0 | ||
| 51 | while index < chars.count { | ||
| 52 | while index < chars.count, kind(chars[index]) == nil { index += 1 } | ||
| 53 | guard index < chars.count else { break } | ||
| 54 | let start = index | ||
| 55 | var digits = kind(chars[index]) == "0" | ||
| 56 | index += 1 | ||
| 57 | while index < chars.count, let k = kind(chars[index]) { | ||
| 58 | digits = digits && k == "0" | ||
| 59 | index += 1 | ||
| 60 | } | ||
| 61 | let text = String(String.UnicodeScalarView(chars[start..<index])) | ||
| 62 | tokens.append(digits ? .number(Int(text) ?? 0) : .word(text)) | ||
| 63 | } | ||
| 64 | return tokens | ||
| 65 | } | ||
| 66 | |||
| 67 | /// `parse-time-string`'s fallback: (sec, min, hour, day, month, year, weekday). | ||
| 68 | static func parseTime(_ string: String) -> [Int?] { | ||
| 69 | var time: [Int?] = Array(repeating: nil, count: 9) | ||
| 70 | func digits(_ text: String, _ range: Range<Int>) -> Int? { | ||
| 71 | let chars = Array(text) | ||
| 72 | guard range.upperBound <= chars.count else { return nil } | ||
| 73 | return Int(String(chars[range])) | ||
| 74 | } | ||
| 75 | for token in tokenize(string) { | ||
| 76 | if time[6] == nil, case .word(let w) = token, let day = weekdays.first(where: { $0.0 == w }) { | ||
| 77 | time[6] = day.1 | ||
| 78 | } else if time[3] == nil, case .number(let n) = token, (1...31).contains(n) { | ||
| 79 | time[3] = n | ||
| 80 | } else if time[4] == nil, case .word(let w) = token, let month = months.first(where: { $0.0 == w }) { | ||
| 81 | time[4] = month.1 | ||
| 82 | } else if time[5] == nil, case .number(let n) = token, n >= 100 { | ||
| 83 | time[5] = n | ||
| 84 | } else if time[2] == nil, case .word(let w) = token, w.count == 8, Array(w)[2] == ":", Array(w)[5] == ":" { | ||
| 85 | (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), digits(w, 6..<8)) | ||
| 86 | } else if time[5] == nil, case .word(let w) = token, w.count == 10, Array(w)[4] == "-", Array(w)[7] == "-" { | ||
| 87 | (time[5], time[4], time[3]) = (digits(w, 0..<4), digits(w, 5..<7), digits(w, 8..<10)) | ||
| 88 | } else if time[2] == nil, case .word(let w) = token, w.count == 5, Array(w)[2] == ":" { | ||
| 89 | (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), 0) | ||
| 90 | } else if time[2] == nil, case .word(let w) = token, w.count == 4, Array(w)[1] == ":" { | ||
| 91 | (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), 0) | ||
| 92 | } else if time[2] == nil, case .word(let w) = token, w.count == 7, Array(w)[1] == ":" { | ||
| 93 | (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), digits(w, 5..<7)) | ||
| 94 | } else if time[5] == nil, case .number(let n) = token, (50...110).contains(n) { | ||
| 95 | time[5] = 1900 + n | ||
| 96 | } else if time[5] == nil, case .number(let n) = token, (0...49).contains(n) { | ||
| 97 | time[5] = 2000 + n | ||
| 98 | } | ||
| 99 | } | ||
| 100 | return time | ||
| 101 | } | ||
| 102 | |||
| 103 | /// `org-small-year-to-year`. | ||
| 104 | static func smallYear(_ year: Int, currentYear: Int) -> Int { | ||
| 105 | guard year < 100 else { return year } | ||
| 106 | let century = currentYear / 100 | ||
| 107 | let offset = year - currentYear % 100 | ||
| 108 | if offset > 30 { return (century - 1) * 100 + year } | ||
| 109 | if offset > -70 { return century * 100 + year } | ||
| 110 | return (century + 1) * 100 + year | ||
| 111 | } | ||
| 112 | |||
| 113 | /// Replaces the first match of `pattern` in `string`, with `$n` group references. | ||
| 114 | private static func replaceFirst(_ string: inout String, _ match: NSTextCheckingResult, _ template: String) { | ||
| 115 | let ns = string as NSString | ||
| 116 | var result = "" | ||
| 117 | var chars = Array(template) | ||
| 118 | var i = 0 | ||
| 119 | while i < chars.count { | ||
| 120 | if chars[i] == "\\", i + 1 < chars.count, let n = chars[i + 1].wholeNumberValue { | ||
| 121 | let range = match.range(at: n) | ||
| 122 | if range.location != NSNotFound { result += ns.substring(with: range) } | ||
| 123 | i += 2 | ||
| 124 | } else { | ||
| 125 | result.append(chars[i]) | ||
| 126 | i += 1 | ||
| 127 | } | ||
| 128 | } | ||
| 129 | chars = [] | ||
| 130 | string = ns.replacingCharacters(in: match.range, with: result) | ||
| 131 | } | ||
| 132 | |||
| 133 | private static func firstMatch(_ pattern: String, _ string: String, caseInsensitive: Bool = false) -> NSTextCheckingResult? { | ||
| 134 | let regex = try! NSRegularExpression(pattern: pattern, options: caseInsensitive ? [.caseInsensitive] : []) | ||
| 135 | return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length)) | ||
| 136 | } | ||
| 137 | |||
| 138 | private static func group(_ match: NSTextCheckingResult, _ i: Int, _ string: String) -> String? { | ||
| 139 | let range = match.range(at: i) | ||
| 140 | return range.location == NSNotFound ? nil : (string as NSString).substring(with: range) | ||
| 141 | } | ||
| 142 | |||
| 143 | /// `org-read-date-get-relative`: (shift, unit, relative to the default). | ||
| 144 | static func relative(_ string: String, today: DateComponents, default def: DateComponents) -> (Int, String, Bool, NSTextCheckingResult)? { | ||
| 145 | let names = weekdays.map(\.0).joined(separator: "|") | ||
| 146 | guard let match = firstMatch("^[ \\t]*([-+]{0,2})([0-9]+)?([hdwmy]|(" + names + "))?([ \\t]|$)", string, caseInsensitive: true) else { return nil } | ||
| 147 | let sign = group(match, 1, string) ?? "" | ||
| 148 | guard !sign.isEmpty || match.range(at: 4).location != NSNotFound else { return nil } | ||
| 149 | let direction: Character = sign.last ?? "+" | ||
| 150 | let relativeToDefault = sign.count == 2 | ||
| 151 | let n = group(match, 2, string).flatMap { Int($0) } ?? 1 | ||
| 152 | let what = group(match, 3, string) ?? "d" | ||
| 153 | let date = relativeToDefault ? def : today | ||
| 154 | if let target = weekdays.first(where: { $0.0 == what.lowercased() })?.1 { | ||
| 155 | let weekday = (date.weekday ?? 1) - 1 | ||
| 156 | var delta = (7 + target - weekday) % 7 | ||
| 157 | if delta == 0 { delta = 7 } | ||
| 158 | if direction == "-" { | ||
| 159 | delta -= 7 | ||
| 160 | if delta == 0 { delta = -7 } | ||
| 161 | } | ||
| 162 | if n > 1 { delta += (n - 1) * (direction == "-" ? -7 : 7) } | ||
| 163 | return (delta, "d", relativeToDefault, match) | ||
| 164 | } | ||
| 165 | return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match) | ||
| 166 | } | ||
| 167 | |||
| 168 | /// `org-read-date-analyze` with `org-read-date-prefer-future` t. | ||
| 169 | public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer { | ||
| 170 | let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday] | ||
| 171 | let def = calendar.dateComponents(units, from: defaultDate) | ||
| 172 | let today = calendar.dateComponents(units, from: now) | ||
| 173 | let currentYear = today.year! | ||
| 174 | var ans = input | ||
| 175 | if firstMatch("^[ \\t]*\\.[ \\t]*$", ans) != nil { ans = "+0" } | ||
| 176 | var delta: (Int, String, Bool)? | ||
| 177 | if let relative = relative(ans, today: today, default: def) { | ||
| 178 | delta = (relative.0, relative.1, relative.2) | ||
| 179 | ans = (ans as NSString).replacingCharacters(in: relative.3.range, with: "") | ||
| 180 | } | ||
| 181 | var isoYear: Int?, isoWeekday: Int?, isoWeek: Int? | ||
| 182 | if let match = firstMatch("\\b(?:([0-9]+)-)?[wW]([0-9]{1,2})(?:-([0-6]))?([ \\t]|$)", ans) { | ||
| 183 | isoYear = group(match, 1, ans).flatMap { Int($0) }.map { smallYear($0, currentYear: currentYear) } | ||
| 184 | isoWeekday = group(match, 3, ans).flatMap { Int($0) } | ||
| 185 | isoWeek = group(match, 2, ans).flatMap { Int($0) } | ||
| 186 | ans = (ans as NSString).replacingCharacters(in: match.range, with: "") | ||
| 187 | } | ||
| 188 | var killYear = false | ||
| 189 | if let match = firstMatch("^ *(([0-9]+)-)?([0-1]?[0-9])-([0-3]?[0-9])([^-0-9]|$)", ans) { | ||
| 190 | var year = group(match, 2, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }() | ||
| 191 | year = smallYear(year, currentYear: currentYear) | ||
| 192 | let month = Int(group(match, 3, ans)!)!, day = Int(group(match, 4, ans)!)! | ||
| 193 | replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5") | ||
| 194 | } | ||
| 195 | if let match = firstMatch("^ *(3[01]|0?[1-9]|[12][0-9])\\. ?(0?[1-9]|1[012])\\.( ?[1-9][0-9]{3})?", ans) { | ||
| 196 | let year = group(match, 3, ans).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? { killYear = true; return currentYear }() | ||
| 197 | let day = Int(group(match, 1, ans)!)!, month = Int(group(match, 2, ans)!)! | ||
| 198 | replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day)) | ||
| 199 | } | ||
| 200 | if let match = firstMatch("^ *(0?[1-9]|1[012])/(0?[1-9]|[12][0-9]|3[01])(/([0-9]+))?([^/0-9]|$)", ans) { | ||
| 201 | var year = group(match, 4, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }() | ||
| 202 | year = smallYear(year, currentYear: currentYear) | ||
| 203 | let month = Int(group(match, 1, ans)!)!, day = Int(group(match, 2, ans)!)! | ||
| 204 | replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5") | ||
| 205 | } | ||
| 206 | let plainTime = "(^|[^+])[012]?[0-9]:[0-9][0-9]([ \\t\\n]|$)" | ||
| 207 | for _ in 0..<2 where firstMatch(plainTime, ans) == nil { | ||
| 208 | guard let match = firstMatch("([012]?[0-9])(:([0-5][0-9]))?(am|AM|pm|PM)\\b", ans) else { break } | ||
| 209 | var hour = Int(group(match, 1, ans)!)! | ||
| 210 | let minute = group(match, 3, ans).flatMap { Int($0) } ?? 0 | ||
| 211 | let pm = group(match, 4, ans)!.lowercased() == "pm" | ||
| 212 | if hour == 12, !pm { hour = 0 } else if pm, hour < 12 { hour += 12 } | ||
| 213 | ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute)) | ||
| 214 | } | ||
| 215 | for _ in 0..<2 where firstMatch(plainTime, ans) == nil { | ||
| 216 | guard let match = firstMatch("(?:([012]?[0-9])?h([0-5][0-9]))|(?:([012]?[0-9])h([0-5][0-9])?)\\b", ans) else { break } | ||
| 217 | let hour = (group(match, 1, ans) ?? group(match, 3, ans)).flatMap { Int($0) } ?? 0 | ||
| 218 | let minute = (group(match, 2, ans) ?? group(match, 4, ans)).flatMap { Int($0) } ?? 0 | ||
| 219 | ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute)) | ||
| 220 | } | ||
| 221 | if let match = firstMatch("([012]?[0-9]):([0-6][0-9])\\+([012]?[0-9])(:([0-5][0-9]))?", ans) { | ||
| 222 | let hour = Int(group(match, 1, ans)!)!, minute = Int(group(match, 2, ans)!)! | ||
| 223 | var h2 = hour + Int(group(match, 3, ans)!)! | ||
| 224 | var m2 = minute + (group(match, 5, ans).flatMap { Int($0) } ?? 0) | ||
| 225 | if m2 >= 60 { h2 += 1; m2 -= 60 } | ||
| 226 | ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d-%02d:%02d", hour, minute, h2, m2)) | ||
| 227 | } | ||
| 228 | var timeGiven = false | ||
| 229 | var endTime: String? | ||
| 230 | let timeOfDay = "(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b)(--?(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b))?" | ||
| 231 | if let match = firstMatch(timeOfDay, ans), let end = group(match, 8, ans) { | ||
| 232 | endTime = end | ||
| 233 | let range = match.range(at: 7) | ||
| 234 | ans = (ans as NSString).replacingCharacters(in: range, with: "") | ||
| 235 | } | ||
| 236 | let tl = parseTime(ans) | ||
| 237 | var futurep = false | ||
| 238 | var day = tl[3] ?? def.day! | ||
| 239 | let month: Int | ||
| 240 | if let m = tl[4] { | ||
| 241 | month = m | ||
| 242 | } else if tl[3] != nil { | ||
| 243 | futurep = true | ||
| 244 | month = day < today.day! ? today.month! + 1 : today.month! | ||
| 245 | } else { | ||
| 246 | month = def.month! | ||
| 247 | } | ||
| 248 | var year: Int | ||
| 249 | if !killYear, let y = tl[5] { | ||
| 250 | year = y | ||
| 251 | } else if futurep { | ||
| 252 | year = (month > today.month! || day >= today.day!) ? today.year! : today.year! + 1 | ||
| 253 | } else if tl[4] != nil { | ||
| 254 | futurep = true | ||
| 255 | if month > today.month! { year = today.year! } else if month < today.month! { year = today.year! + 1 } else if day < today.day! { year = today.year! + 1 } else { year = today.year! } | ||
| 256 | } else { | ||
| 257 | year = def.year! | ||
| 258 | } | ||
| 259 | var month2 = month | ||
| 260 | var hour = tl[2] ?? def.hour! | ||
| 261 | let minute = tl[1] ?? def.minute! | ||
| 262 | var wday = tl[6] | ||
| 263 | if let isoWeek { | ||
| 264 | let isoYearValue = isoYear ?? year | ||
| 265 | let weekday = isoWeekday ?? wday ?? 1 | ||
| 266 | wday = nil | ||
| 267 | var iso = Calendar(identifier: .iso8601) | ||
| 268 | iso.timeZone = calendar.timeZone | ||
| 269 | let isoComponents = DateComponents(weekday: weekday == 0 ? 1 : weekday + 1, weekOfYear: isoWeek, yearForWeekOfYear: isoYearValue) | ||
| 270 | if let date = iso.date(from: isoComponents) { | ||
| 271 | let c = iso.dateComponents([.year, .month, .day], from: date) | ||
| 272 | (year, month2, day) = (c.year!, c.month!, c.day!) | ||
| 273 | } | ||
| 274 | } else if let (n, unit, relativeToDefault) = delta { | ||
| 275 | if !relativeToDefault { (day, month2, year) = (today.day!, today.month!, today.year!) } | ||
| 276 | switch unit.lowercased() { | ||
| 277 | case "h", "": | ||
| 278 | timeGiven = true | ||
| 279 | hour += n | ||
| 280 | case "d": day += n | ||
| 281 | case "w": day += 7 * n | ||
| 282 | case "m": month2 += n | ||
| 283 | case "y": year += n | ||
| 284 | default: break | ||
| 285 | } | ||
| 286 | } else if let target = wday, tl[3] == nil { | ||
| 287 | let base = calendar.date(from: DateComponents(year: year, month: month2, day: 1))! | ||
| 288 | let date = calendar.date(byAdding: .day, value: day - 1, to: base)! | ||
| 289 | let current = calendar.component(.weekday, from: date) - 1 | ||
| 290 | if target != current { day += ((target - current + 7) % 7 + 7) % 7 } | ||
| 291 | } | ||
| 292 | if tl[2] != nil { timeGiven = true } | ||
| 293 | if year < 100 { year += 2000 } | ||
| 294 | _ = futurep | ||
| 295 | return Answer(year: year, month: month2, day: day, hour: hour, minute: minute, timeGiven: timeGiven, endTime: endTime) | ||
| 296 | } | ||
| 297 | } | ||
Tests/OrgCoreTests/ReadDateTests.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// Emacs Lisp that runs `body` with the clock stopped at `seconds` (UTC in the oracle). | ||
| 6 | func frozenClock(_ seconds: Int, _ body: String) -> String { | ||
| 7 | """ | ||
| 8 | (cl-letf* ((frozen (seconds-to-time \(seconds))) | ||
| 9 | (odt (symbol-function 'decode-time)) | ||
| 10 | (oft (symbol-function 'format-time-string)) | ||
| 11 | (otl (symbol-function 'time-less-p)) | ||
| 12 | ((symbol-function 'current-time) (lambda () frozen)) | ||
| 13 | ((symbol-function 'org-today) (lambda () (time-to-days frozen))) | ||
| 14 | ((symbol-function 'time-less-p) (lambda (a b) (funcall otl (or a frozen) (or b frozen)))) | ||
| 15 | ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form))) | ||
| 16 | ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone)))) | ||
| 17 | \(body)) | ||
| 18 | """ | ||
| 19 | } | ||
| 20 | |||
| 21 | struct ReadDateTests { | ||
| 22 | /// Thu 2026-09-10 00:26 UTC. | ||
| 23 | static let now = Date(timeIntervalSince1970: 1_789_000_000) | ||
| 24 | static var utc: Calendar { | ||
| 25 | var calendar = Calendar(identifier: .gregorian) | ||
| 26 | calendar.timeZone = TimeZone(identifier: "UTC")! | ||
| 27 | return calendar | ||
| 28 | } | ||
| 29 | |||
| 30 | static func format(_ answer: ReadDate.Answer) -> String { | ||
| 31 | let c = answer.components(utc) | ||
| 32 | let date = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | ||
| 33 | return (answer.timeGiven ? date + String(format: " %02d:%02d", c.hour!, c.minute!) : date) + "|" + (answer.endTime ?? "nil") | ||
| 34 | } | ||
| 35 | |||
| 36 | static let inputs = [ | ||
| 37 | "", ".", "+0", "+1", "+2d", "-3d", "+1w", "+2m", "-1y", "++3", "fri", "+fri", "-fri", "+2fri", "mon", | ||
| 38 | "2026-10-07", "2026-10-7", "10-7", "7.10.", "7.10.2027", "10/7", "10/7/27", "12/31", | ||
| 39 | "3", "15", "sep 15", "15 sep", "jan", "dec 25", "2027", | ||
| 40 | "10:00", "9:30", "10am", "3pm", "12am", "12:30pm", "10h", "10h30", "h45", | ||
| 41 | "10:00-11:30", "10:00+1:30", "fri 10:00", "2026-10-07 14:15", "+2d 9:00", "+3h", | ||
| 42 | "w42", "2027-w01", "w3-5", "tomorrow", "nonsense", | ||
| 43 | ] | ||
| 44 | |||
| 45 | @Test func examples() { | ||
| 46 | let answer = ReadDate.analyze("fri 10:00", default: Self.now, now: Self.now, calendar: Self.utc) | ||
| 47 | #expect(Self.format(answer) == "2026-09-11 10:00|nil") | ||
| 48 | #expect(Self.format(ReadDate.analyze("10:00-11:30", default: Self.now, now: Self.now, calendar: Self.utc)) == "2026-09-10 10:00|11:30") | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 52 | func readDateMatchesEmacs() throws { | ||
| 53 | try #require(EmacsOracle.isAvailable) | ||
| 54 | let cases = Self.inputs.map { input in | ||
| 55 | EmacsOracle.Case(text: "", point: 1, form: frozenClock(1_789_000_000, """ | ||
| 56 | (eval '(let ((org-time-was-given nil) (org-end-time-was-given nil)) | ||
| 57 | (let ((d (org-read-date nil nil "\(input)"))) | ||
| 58 | (insert d "|" (or org-end-time-was-given "nil")))) | ||
| 59 | nil) | ||
| 60 | """)) | ||
| 61 | } | ||
| 62 | var mismatches = 0 | ||
| 63 | for (input, emacs) in zip(Self.inputs, try EmacsOracle.run(cases)) { | ||
| 64 | let ours = Self.format(ReadDate.analyze(input, default: Self.now, now: Self.now, calendar: Self.utc)) | ||
| 65 | let theirs = emacs.error.isEmpty ? emacs.text : "error: \(emacs.error)" | ||
| 66 | if ours != theirs { | ||
| 67 | mismatches += 1 | ||
| 68 | Issue.record("\(input.debugDescription): ours \(ours), emacs \(theirs)") | ||
| 69 | } | ||
| 70 | } | ||
| 71 | #expect(mismatches == 0) | ||
| 72 | } | ||
| 73 | } | ||