Tests/OrgPresentationTests/AgendaRowsTests.swift
196 lines · 10553 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", "Sched. 4d ago", .overdue, .scheduled)
72 check("Ancient chore", "Sched. 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 guttersOnOtherDays() {
90 let later = Self.rows(day: Self.today + 4)
91 #expect(later["Renew passport"]?.gutter == "Due" && later["Renew passport"]?.status == .normal)
92 let earlier = Self.rows(day: Self.today - 3)
93 #expect(earlier["Send forms"]?.gutter == "Due" && earlier["Send forms"]?.status == .overdue)
94 }
95
96 @Test func emptyDaysAreHiddenButTodayStays() {
97 let days = Self.days(start: Self.today - 1, span: 8)
98 let shown = AgendaRows.groups(days, today: Self.today).map(\.day)
99 // Sunday has the conference and the habit's first day; Wednesday to Saturday only Friday's deadline.
100 #expect(shown == [Self.today - 1, Self.today, Self.today + 1, Self.today + 4, Self.today + 6])
101 #expect(AgendaRows.groups(days, today: Self.today, showAllDates: true).count == 8)
102 let empty = AgendaRows.groups(days, today: Self.today, keep: { _ in false })
103 #expect(empty.map(\.day) == [Self.today] && empty[0].isEmpty && empty[0].isToday)
104 }
105
106 @Test func overdueEntriesGroupAboveTodayOldestFirst() {
107 let today = AgendaRows.groups(Self.days(), today: Self.today)[0]
108 #expect(today.overdue.map(\.title) == ["Ancient chore", "Pay rent", "Send forms"])
109 #expect(!today.rows.contains { $0.status == .overdue && $0.icon != .habit })
110 #expect(today.rows.contains { $0.title == "Stretch" })
111 #expect(Set(today.rows.map(\.id)).count == today.rows.count)
112 }
113
114 @Test func eventsMergeByTime() {
115 let day = Self.today
116 let events = [
117 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),
118 AgendaEvent(id: "b", title: "Holiday", calendarID: "c2", calendarTitle: "Home", color: nil, start: Self.date(day, 0), end: Self.date(day + 1, 0), isAllDay: true),
119 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),
120 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),
121 ]
122 let groups = AgendaRows.groups(Self.days(start: day - 1, span: 3), events: events, today: day, calendar: Self.utc)
123 let today = groups.first { $0.isToday }!
124 #expect(today.rows.prefix(2).map(\.title) == ["Holiday", "Flight"])
125 #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"])
126 #expect(today.rows[4].title == "Dentist" && today.rows[5].title == "Lunch")
127 #expect(today.rows[3].isEvent && today.rows[3].category == "Work" && today.rows[3].color == RGBA(hex: "#ff0000") && today.rows[3].icon == .event)
128 let before = groups.first { $0.day == day - 1 }!
129 #expect(before.rows.first { $0.title == "Flight" }?.gutter == "22:00")
130 let after = groups.first { $0.day == day + 1 }!
131 #expect(after.rows.first { $0.title == "Flight" }?.gutter == "Until 02:00")
132 // The all-day event ends at midnight: one day only.
133 #expect(!after.rows.contains { $0.title == "Holiday" })
134 }
135
136 @Test func nowLineGoesAmongTimedRows() {
137 let today = AgendaRows.groups(Self.days(), today: Self.today)[0]
138 let timed = today.rows.indices.filter { today.rows[$0].start != nil }
139 #expect(today.nowIndex(now: 700) == timed[0])
140 #expect(today.nowIndex(now: 800) == timed[1])
141 #expect(today.nowIndex(now: 1000) == timed[1])
142 #expect(today.nowIndex(now: 1500) == timed.last! + 1)
143 #expect(AgendaRows.nowIndex([], now: 900) == nil)
144 let later = AgendaRows.groups(Self.days(start: Self.today + 4), today: Self.today)[0]
145 #expect(later.nowIndex(now: 900) == nil)
146 }
147}
148
149struct AgendaThemeTests {
150 @Test func newKeysHaveDefaultsInBothAppearances() {
151 for key in ["todo-next", "todo-waiting", "todo-cancelled", "priority-a", "priority-b", "priority-c", "agenda-overdue", "agenda-due-soon", "agenda-event", "agenda-today"] {
152 #expect(ThemeSpec.default.light[key] != nil && ThemeSpec.default.dark[key] != nil, "\(key)")
153 }
154 }
155
156 @Test func keywordClasses() {
157 #expect(ThemeSpec.TodoClass.of("TODO", isDone: false) == .todo)
158 #expect(ThemeSpec.TodoClass.of("STRT", isDone: false) == .next)
159 #expect(ThemeSpec.TodoClass.of("NEXT", isDone: false) == .next)
160 #expect(ThemeSpec.TodoClass.of("WAIT", isDone: false) == .waiting)
161 #expect(ThemeSpec.TodoClass.of("HOLD", isDone: false) == .waiting)
162 #expect(ThemeSpec.TodoClass.of("DONE", isDone: true) == .done)
163 #expect(ThemeSpec.TodoClass.of("KILL", isDone: true) == .cancelled)
164 #expect(ThemeSpec.TodoClass.of("SKIPPED", isDone: true) == .cancelled)
165 // Only done keywords cancel.
166 #expect(ThemeSpec.TodoClass.of("SKIPPED", isDone: false) == .todo)
167 }
168
169 @Test func colorsResolveOverridesFirst() throws {
170 let (spec, problems) = ThemeSpec.reading(try TOML.parse("""
171 [theme.todo]
172 WAIT = "#010203"
173 [theme.category]
174 work = "#0a0b0c"
175 """))
176 #expect(problems.isEmpty)
177 #expect(spec.todoColor("WAIT", isDone: false, dark: true)?.hex == "#010203")
178 #expect(spec.todoColor("HOLD", isDone: false, dark: true) == ThemeSpec.default.dark["todo-waiting"])
179 #expect(spec.todoColor("KILL", isDone: true, dark: false) == ThemeSpec.default.light["todo-cancelled"])
180 #expect(spec.categoryColor("work", dark: false).hex == "#0a0b0c" && spec.categoryColor("work", dark: true).hex == "#0a0b0c")
181 #expect(spec.priorityColor("A", dark: false) == ThemeSpec.default.light["priority-a"])
182 #expect(spec.priorityColor("D", dark: false) == ThemeSpec.default.light["priority"])
183 #expect(spec.statusColor(.overdue, dark: true) == ThemeSpec.default.dark["agenda-overdue"])
184 let event = AgendaEvent(id: "x", title: "", calendarID: "", calendarTitle: "", color: nil, start: Date(), end: Date(), isAllDay: true)
185 #expect(spec.eventColor(event, dark: false) == ThemeSpec.default.light["agenda-event"])
186 }
187
188 @Test func categoryHuesAreStable() {
189 #expect(ThemeSpec.categoryHue("work") == 264)
190 #expect(ThemeSpec.categoryHue("home") == 206)
191 #expect(ThemeSpec.default.categoryColor("home", dark: false) == ThemeSpec.default.categoryColor("home", dark: false))
192 #expect(ThemeSpec.default.categoryColor("home", dark: false) != ThemeSpec.default.categoryColor("home", dark: true))
193 #expect(RGBA(hue: 0, saturation: 1, lightness: 0.5).hex == "#ff0000")
194 #expect(RGBA(hue: 240, saturation: 1, lightness: 0.5).hex == "#0000ff")
195 }
196}