krz/orgstar

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

Tests/OrgPresentationTests/AgendaRowsTests.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Tests/OrgPresentationTests/AgendaRowsTests.swift history · blame · raw

196 lines · 10539 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 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}