Tests/OrgPresentationTests/AgendaRowsTests.swift
216 lines · 11595 bytes
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgPresentation
5
6struct AgendaRowsTests {
7 static let file = """
8 #+CATEGORY: life
9 * TODO [#B] Pay rent [0/2] :home:
10 SCHEDULED: <2026-10-01 Thu>
11 * TODO Ancient chore
12 SCHEDULED: <2025-05-24 Sat>
13 * TODO Send forms
14 DEADLINE: <2026-10-02 Fri>
15 * TODO File taxes
16 DEADLINE: <2026-10-05 Mon>
17 * TODO Renew passport
18 DEADLINE: <2026-10-09 Fri>
19 * TODO Water plants
20 SCHEDULED: <2026-10-05 Mon>
21 * DONE Took out bins
22 SCHEDULED: <2026-10-05 Mon>
23 * Standup
24 SCHEDULED: <2026-10-05 Mon 08:00>
25 * Dentist <2026-10-05 Mon 13:50-14:30>
26 * Birthday <2026-10-05 Mon>
27 * Conference
28 <2026-10-04 Sun>--<2026-10-06 Tue>
29 * TODO Stretch
30 SCHEDULED: <2026-10-04 Sun .+1d>
31 :PROPERTIES:
32 :STYLE: habit
33 :END:
34 * Far off
35 <2026-10-11 Sun>
36 """
37
38 static let today = Days.absolute(year: 2026, month: 10, day: 5)
39 static let utc: Calendar = {
40 var c = Calendar(identifier: .gregorian)
41 c.timeZone = TimeZone(identifier: "UTC")!
42 return c
43 }()
44
45 static func days(start: Int = today, span: Int = 1) -> [AgendaDay] {
46 let source = AgendaSource(path: "life.org", text: file, defaults: OrgSettings.default.withTodoKeywords("TODO | DONE")!)
47 return Agenda.list([source], start: start, days: span, today: today, now: (10, 0))
48 }
49
50 static func rows(day: Int = today) -> [String: AgendaRow] {
51 var rows: [String: AgendaRow] = [:]
52 for item in days(start: day)[0].items {
53 guard let row = AgendaRow(item: item, day: day, today: today) else { continue }
54 rows[row.title] = rows[row.title] ?? row
55 }
56 return rows
57 }
58
59 static func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date {
60 Date(timeIntervalSince1970: Double((day - 719163) * 86400 + hour * 3600 + minute * 60))
61 }
62
63 @Test func guttersAndStatusesOnToday() {
64 let rows = Self.rows()
65 func check(_ title: String, _ gutter: String, _ status: AgendaRow.Status, _ icon: AgendaRow.Icon?, sourceLocation: SourceLocation = #_sourceLocation) {
66 let row = rows[title]
67 #expect(row?.gutter == gutter, "\(title)", sourceLocation: sourceLocation)
68 #expect(row?.status == status, "\(title)", sourceLocation: sourceLocation)
69 #expect(row?.icon == icon, "\(title)", sourceLocation: sourceLocation)
70 }
71 check("Pay rent", "4d ago", .overdue, .scheduled)
72 check("Ancient chore", "1y 4mo ago", .overdue, .scheduled)
73 check("Send forms", "3d late", .overdue, .deadline)
74 check("File taxes", "Due today", .dueSoon, .deadline)
75 check("Renew passport", "Due in 4d", .dueSoon, .deadline)
76 check("Water plants", "Scheduled", .normal, .scheduled)
77 check("Took out bins", "Scheduled", .done, .scheduled)
78 check("Standup", "08:00", .normal, .scheduled)
79 check("Dentist", "13:50–14:30", .normal, .timestamp)
80 check("Birthday", "All day", .normal, .timestamp)
81 check("Conference", "Day 2/3", .normal, .timestamp)
82 check("Stretch", "Habit", .overdue, .habit)
83 #expect(rows["Dentist"]?.start == 1350 && rows["Dentist"]?.end == 1430)
84 let rent = rows["Pay rent"]!
85 #expect(rent.category == "life" && rent.tags == ["home"] && rent.todo == "TODO" && rent.priority == "B" && rent.progress == "0/2")
86 #expect(!rent.isEvent && rent.item != nil)
87 }
88
89 @Test func logRowsKeepTheDurationAndState() {
90 let text = """
91 * DONE Write report
92 CLOSED: [2026-10-05 Mon 11:00]
93 :LOGBOOK:
94 - State "DONE" from "TODO" [2026-10-05 Mon 11:00]
95 CLOCK: [2026-10-05 Mon 09:00]--[2026-10-05 Mon 10:30] => 1:30
96 :END:
97 """
98 var options = AgendaOptions()
99 options.logMode = true
100 options.logItems = ["closed", "clock", "state"]
101 let source = AgendaSource(path: "w.org", text: text, defaults: OrgSettings.default.withTodoKeywords("TODO | DONE")!)
102 let items = Agenda.list([source], start: Self.today, days: 1, today: Self.today, now: (12, 0), options: options)[0].items
103 let rows = items.compactMap { AgendaRow(item: $0, day: Self.today, today: Self.today) }
104 #expect(rows.first { $0.item?.kind == .clock }?.detail == "1:30")
105 #expect(rows.first { $0.item?.kind == .state }?.detail == "DONE")
106 #expect(rows.first { $0.item?.kind == .closed }?.detail == nil)
107 }
108
109 @Test func guttersOnOtherDays() {
110 let later = Self.rows(day: Self.today + 4)
111 #expect(later["Renew passport"]?.gutter == "Due" && later["Renew passport"]?.status == .normal)
112 let earlier = Self.rows(day: Self.today - 3)
113 #expect(earlier["Send forms"]?.gutter == "Due" && earlier["Send forms"]?.status == .overdue)
114 }
115
116 @Test func emptyDaysAreHiddenButTodayStays() {
117 let days = Self.days(start: Self.today - 1, span: 8)
118 let shown = AgendaRows.groups(days, today: Self.today).map(\.day)
119 // Sunday has the conference and the habit's first day; Wednesday to Saturday only Friday's deadline.
120 #expect(shown == [Self.today - 1, Self.today, Self.today + 1, Self.today + 4, Self.today + 6])
121 #expect(AgendaRows.groups(days, today: Self.today, showAllDates: true).count == 8)
122 let empty = AgendaRows.groups(days, today: Self.today, keep: { _ in false })
123 #expect(empty.map(\.day) == [Self.today] && empty[0].isEmpty && empty[0].isToday)
124 }
125
126 @Test func overdueEntriesGroupAboveTodayOldestFirst() {
127 let today = AgendaRows.groups(Self.days(), today: Self.today)[0]
128 #expect(today.overdue.map(\.title) == ["Ancient chore", "Pay rent", "Send forms"])
129 #expect(!today.rows.contains { $0.status == .overdue && $0.icon != .habit })
130 #expect(today.rows.contains { $0.title == "Stretch" })
131 #expect(Set(today.rows.map(\.id)).count == today.rows.count)
132 }
133
134 @Test func eventsMergeByTime() {
135 let day = Self.today
136 let events = [
137 AgendaEvent(id: "a", title: "Coffee", calendarID: "c1", calendarTitle: "Work", color: RGBA(hex: "#ff0000"), start: Self.date(day, 9), end: Self.date(day, 9, 30), isAllDay: false),
138 AgendaEvent(id: "b", title: "Holiday", calendarID: "c2", calendarTitle: "Home", color: nil, start: Self.date(day, 0), end: Self.date(day + 1, 0), isAllDay: true),
139 AgendaEvent(id: "c", title: "Flight", calendarID: "c1", calendarTitle: "Work", color: nil, start: Self.date(day - 1, 22), end: Self.date(day + 1, 2), isAllDay: false),
140 AgendaEvent(id: "d", title: "Lunch", calendarID: "c1", calendarTitle: "Work", color: nil, start: Self.date(day, 13, 50), end: Self.date(day, 14, 30), isAllDay: false),
141 ]
142 let groups = AgendaRows.groups(Self.days(start: day - 1, span: 3), events: events, today: day, calendar: Self.utc)
143 let today = groups.first { $0.isToday }!
144 #expect(today.rows.prefix(2).map(\.title) == ["Holiday", "Flight"])
145 #expect(today.rows.map(\.gutter).prefix(6) == ["All day", "All day", "08:00", "09:00–09:30", "13:50–14:30", "13:50–14:30"])
146 #expect(today.rows[4].title == "Dentist" && today.rows[5].title == "Lunch")
147 #expect(today.rows[3].isEvent && today.rows[3].category == "Work" && today.rows[3].color == RGBA(hex: "#ff0000") && today.rows[3].icon == .event)
148 let before = groups.first { $0.day == day - 1 }!
149 #expect(before.rows.first { $0.title == "Flight" }?.gutter == "22:00")
150 let after = groups.first { $0.day == day + 1 }!
151 #expect(after.rows.first { $0.title == "Flight" }?.gutter == "Until 02:00")
152 // The all-day event ends at midnight: one day only.
153 #expect(!after.rows.contains { $0.title == "Holiday" })
154 }
155
156 @Test func nowLineGoesAmongTimedRows() {
157 let today = AgendaRows.groups(Self.days(), today: Self.today)[0]
158 let timed = today.rows.indices.filter { today.rows[$0].start != nil }
159 #expect(today.nowIndex(now: 700) == timed[0])
160 #expect(today.nowIndex(now: 800) == timed[1])
161 #expect(today.nowIndex(now: 1000) == timed[1])
162 #expect(today.nowIndex(now: 1500) == timed.last! + 1)
163 #expect(AgendaRows.nowIndex([], now: 900) == nil)
164 let later = AgendaRows.groups(Self.days(start: Self.today + 4), today: Self.today)[0]
165 #expect(later.nowIndex(now: 900) == nil)
166 }
167}
168
169struct AgendaThemeTests {
170 @Test func newKeysHaveDefaultsInBothAppearances() {
171 for key in ["todo-next", "todo-waiting", "todo-cancelled", "priority-a", "priority-b", "priority-c", "agenda-overdue", "agenda-due-soon", "agenda-event", "agenda-today"] {
172 #expect(ThemeSpec.default.light[key] != nil && ThemeSpec.default.dark[key] != nil, "\(key)")
173 }
174 }
175
176 @Test func keywordClasses() {
177 #expect(ThemeSpec.TodoClass.of("TODO", isDone: false) == .todo)
178 #expect(ThemeSpec.TodoClass.of("STRT", isDone: false) == .next)
179 #expect(ThemeSpec.TodoClass.of("NEXT", isDone: false) == .next)
180 #expect(ThemeSpec.TodoClass.of("WAIT", isDone: false) == .waiting)
181 #expect(ThemeSpec.TodoClass.of("HOLD", isDone: false) == .waiting)
182 #expect(ThemeSpec.TodoClass.of("DONE", isDone: true) == .done)
183 #expect(ThemeSpec.TodoClass.of("KILL", isDone: true) == .cancelled)
184 #expect(ThemeSpec.TodoClass.of("SKIPPED", isDone: true) == .cancelled)
185 // Only done keywords cancel.
186 #expect(ThemeSpec.TodoClass.of("SKIPPED", isDone: false) == .todo)
187 }
188
189 @Test func colorsResolveOverridesFirst() throws {
190 let (spec, problems) = ThemeSpec.reading(try TOML.parse("""
191 [theme.todo]
192 WAIT = "#010203"
193 [theme.category]
194 work = "#0a0b0c"
195 """))
196 #expect(problems.isEmpty)
197 #expect(spec.todoColor("WAIT", isDone: false, dark: true)?.hex == "#010203")
198 #expect(spec.todoColor("HOLD", isDone: false, dark: true) == ThemeSpec.default.dark["todo-waiting"])
199 #expect(spec.todoColor("KILL", isDone: true, dark: false) == ThemeSpec.default.light["todo-cancelled"])
200 #expect(spec.categoryColor("work", dark: false).hex == "#0a0b0c" && spec.categoryColor("work", dark: true).hex == "#0a0b0c")
201 #expect(spec.priorityColor("A", dark: false) == ThemeSpec.default.light["priority-a"])
202 #expect(spec.priorityColor("D", dark: false) == ThemeSpec.default.light["priority"])
203 #expect(spec.statusColor(.overdue, dark: true) == ThemeSpec.default.dark["agenda-overdue"])
204 let event = AgendaEvent(id: "x", title: "", calendarID: "", calendarTitle: "", color: nil, start: Date(), end: Date(), isAllDay: true)
205 #expect(spec.eventColor(event, dark: false) == ThemeSpec.default.light["agenda-event"])
206 }
207
208 @Test func categoryHuesAreStable() {
209 #expect(ThemeSpec.categoryHue("work") == 264)
210 #expect(ThemeSpec.categoryHue("home") == 206)
211 #expect(ThemeSpec.default.categoryColor("home", dark: false) == ThemeSpec.default.categoryColor("home", dark: false))
212 #expect(ThemeSpec.default.categoryColor("home", dark: false) != ThemeSpec.default.categoryColor("home", dark: true))
213 #expect(RGBA(hue: 0, saturation: 1, lightness: 0.5).hex == "#ff0000")
214 #expect(RGBA(hue: 240, saturation: 1, lightness: 0.5).hex == "#0000ff")
215 }
216}