Tests/OrgCoreTests/HabitTests.swift
179 lines · 7762 bytes
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}