krz/orgstar

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

Tests/OrgPresentationTests/AgendaRowsTests.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Tests/OrgPresentationTests/AgendaRowsTests.swift history · blame · raw

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}