krz/orgstar

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

Tests/OrgCoreTests/HabitTests.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Tests/OrgCoreTests/HabitTests.swift history · blame · raw

179 lines · 7762 bytes

7 symbols in this file
  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5struct HabitTests {
  6    static let habits = """
  7    * TODO Exercise
  8    SCHEDULED: <2026-10-05 Mon .+1d>
  9    :PROPERTIES:
 10    :STYLE:    habit
 11    :END:
 12    :LOGBOOK:
 13    - State "DONE"       from "TODO"       [2026-10-04 Sun 08:00]
 14    - State "DONE"       from "TODO"       [2026-10-02 Fri 08:10]
 15    - State "DONE"       from "TODO"       [2026-09-28 Mon 07:00]
 16    - State "DONE"       from "TODO"       [2026-09-10 Thu 07:00]
 17    :END:
 18    * TODO Water plants
 19    SCHEDULED: <2026-10-01 Thu .+2d/4d>
 20    :PROPERTIES:
 21    :STYLE:    habit
 22    :END:
 23    - State "DONE"       from "TODO"       [2026-09-29 Tue 18:00]
 24    - State "DONE"       from "TODO"       [2026-09-24 Thu 18:00]
 25    - State "KILL"       from "TODO"       [2026-09-20 Sun 18:00]
 26    * TODO Weekly review
 27    SCHEDULED: <2026-10-08 Thu +1w>
 28    :PROPERTIES:
 29    :STYLE:    habit
 30    :END:
 31    :LOGBOOK:
 32    - State "DONE"       from "TODO"       [2026-10-01 Thu 17:00]
 33    - State "DONE"       from "TODO"       [2026-09-24 Thu 17:00]
 34    - State "DONE"       from "TODO"       [2026-09-16 Wed 17:00]
 35    :END:
 36    * TODO Stretch
 37    SCHEDULED: <2026-10-02 Fri 07:30 ++1d>
 38    :PROPERTIES:
 39    :STYLE:    habit
 40    :END:
 41    :LOGBOOK:
 42    - State "DONE"       from "TODO"       [2026-10-01 Thu 07:40]
 43    - State "DONE"       from "TODO"       [2026-09-29 Tue 07:35]
 44    :END:
 45    * TODO Read
 46    SCHEDULED: <2026-10-03 Sat ++3d/5d>
 47    :PROPERTIES:
 48    :STYLE:    habit
 49    :END:
 50    - CLOSING NOTE [2026-09-30 Wed 21:00] \\\\
 51      finished a chapter
 52    * TODO New habit
 53    SCHEDULED: <2026-10-06 Tue .+1d>
 54    :PROPERTIES:
 55    :STYLE:    habit
 56    :END:
 57    * DONE Done habit
 58    SCHEDULED: <2026-10-05 Mon .+1d>
 59    :PROPERTIES:
 60    :STYLE:    habit
 61    :END:
 62    * TODO Plain task
 63    SCHEDULED: <2026-10-05 Mon>
 64    * TODO Late task
 65    SCHEDULED: <2026-10-03 Sat>
 66    """
 67
 68    static let broken = """
 69    * TODO No repeat
 70    SCHEDULED: <2026-10-05 Mon>
 71    :PROPERTIES:
 72    :STYLE:    habit
 73    :END:
 74    * TODO Short deadline
 75    SCHEDULED: <2026-10-05 Mon .+3d/2d>
 76    :PROPERTIES:
 77    :STYLE:    habit
 78    :END:
 79    """
 80
 81    struct Graph: Codable, Equatable {
 82        var pos: Int
 83        var parse: String?
 84        var glyphs: String?
 85        var faces: [String]?
 86    }
 87
 88    /// `org-habit-parse-todo` and `org-habit-build-graph` for each heading, as Emacs gives them.
 89    static func emacs(_ text: String, today: Int) throws -> [Graph] {
 90        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-habit-\(UUID().uuidString)")
 91        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
 92        defer { try? FileManager.default.removeItem(at: folder) }
 93        let file = folder.appendingPathComponent("h.org")
 94        try text.write(to: file, atomically: true, encoding: .utf8)
 95        let output = folder.appendingPathComponent("out.json")
 96        let script = folder.appendingPathComponent("habit.el")
 97        let words = AgendaOracle.keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ")
 98        try #"""
 99        ;;; -*- lexical-binding: t -*-
100        (require 'org)
101        (require 'org-habit)
102        (require 'json)
103        (setq org-todo-keywords '((sequence \#(words))))
104        \#(frozenClock((today - 719163) * 86400 + 9 * 3600, """
105          (with-current-buffer (find-file-noselect "\(file.path)")
106            (let (result (moment (current-time)))
107              (org-map-entries
108               (lambda ()
109                 (let* ((habit (condition-case nil (org-habit-parse-todo) (error nil)))
110                        (graph (and habit (org-habit-build-graph habit
111                                                                 (time-subtract moment (days-to-time org-habit-preceding-days))
112                                                                 moment
113                                                                 (time-add moment (days-to-time org-habit-following-days))))))
114                   (push `((pos . ,(point))
115                           (parse . ,(and habit (prin1-to-string habit)))
116                           (glyphs . ,graph)
117                           (faces . ,(and graph (vconcat (mapcar (lambda (i) (symbol-name (get-text-property i 'face graph)))
118                                                                 (number-sequence 0 (- (length graph) 2)))))))
119                         result))))
120              (let ((coding-system-for-write 'utf-8-unix))
121                (with-temp-file (getenv "ORACLE_OUTPUT")
122                  (insert (json-encode (vconcat (nreverse result))))))))
123        """))
124        """#.write(to: script, atomically: true, encoding: .utf8)
125        let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"])
126        guard let data = try? Data(contentsOf: output) else { throw AgendaOracle.OracleError(log: log) }
127        return try JSONDecoder().decode([Graph].self, from: data).map { graph in
128            var graph = graph
129            graph.pos = EmacsOracle.offset(graph.pos, in: text)
130            return graph
131        }
132    }
133
134    static func ours(_ text: String, today: Int) -> [Graph] {
135        let defaults = OrgSettings.default.withTodoKeywords(AgendaOracle.keywords)!
136        let source = AgendaSource(path: "h.org", text: text, defaults: defaults)
137        return source.headings.map { heading in
138            guard let habit = heading.habit else { return Graph(pos: heading.start) }
139            let deadline = habit.deadlineDay.map(String.init) ?? "nil"
140            let repeat_ = habit.deadlineRepeatDays.map(String.init) ?? "nil"
141            let done = habit.doneDates.isEmpty ? "nil" : "(" + habit.doneDates.map(String.init).joined(separator: " ") + ")"
142            let parse = "(\(habit.scheduled) \(habit.scheduledRepeat) \(deadline) \(repeat_) \(done) \"\(habit.repeatType)\")"
143            let cells = habit.graph(today: today)
144            let faces = cells.map { cell -> String in
145                let base = cell.face.rawValue.replacingOccurrences(of: "Future", with: "")
146                return "org-habit-\(base)-" + (cell.face.rawValue.hasSuffix("Future") ? "future-face" : "face")
147            }
148            return Graph(pos: heading.start, parse: parse, glyphs: String(cells.map(\.glyph)) + " ", faces: faces)
149        }
150    }
151
152    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
153    func graphsMatchEmacs() throws {
154        for (y, m, d) in [(2026, 10, 5), (2026, 10, 1), (2026, 10, 12), (2026, 9, 20)] {
155            let today = Days.absolute(year: y, month: m, day: d)
156            for text in [Self.habits, Self.broken] {
157                #expect(Self.ours(text, today: today) == (try Self.emacs(text, today: today)), "\(y)-\(m)-\(d)")
158            }
159        }
160    }
161
162    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
163    func agendaMatchesEmacs() throws {
164        let files = [("habits.org", Self.habits)]
165        for (y, m, d) in [(2026, 10, 5), (2026, 10, 3), (2026, 10, 9)] {
166            let today = Days.absolute(year: y, month: m, day: d)
167            try AgendaOracle.compare(files: files, start: today - 1, span: 3, today: today)
168        }
169    }
170
171    @Test func urgency() {
172        let habit = Habit.parse(entry: "", scheduled: "2026-10-01 Thu .+2d/4d", doneKeywords: ["DONE"])!
173        let oct1 = Days.absolute(year: 2026, month: 10, day: 1)
174        #expect(habit.deadline == oct1 + 2)
175        #expect(habit.urgency(today: oct1) == 1000 - 10)
176        #expect(habit.urgency(today: oct1 + 2) == 1000 + 20 + 50 + 100)
177        #expect(habit.urgency(today: oct1 + 4) == 1000 + 40 + 300)
178    }
179}