Packages/MoonriseKit/Tests/ObservingLogTests/ObservingLogTests.swift
202 lines · 10127 bytes
15 symbols in this file
orgRoundTripminimalEntryRoundTripsfolderSaveListDeleteequipmentIDsRoundTripfreeTextEquipmentStillParsesequipmentOrgRoundTripinventorySaveListDeleteequipmentUsagedayobservationKeepsHandAddedPropertiesequipmentKeepsHandAddedPropertiescatalogAndOffsetRoundTripentryWithoutOffsetReadsInDeviceZonesaveStampsOffsetAndCatalogequipmentUsageGroupsByCatalogue
1import Foundation
2import Testing
3@testable import ObservingLog
4
5let when = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z
6
7@Test func orgRoundTrip() throws {
8 let o = Observation(object: "Saturn", date: when, place: "Chicago", latitude: 41.8781, longitude: -87.6298, equipment: "10x50 binoculars", seeing: 3, notes: "Rings nearly edge-on.\n\nTitan visible to the east.")
9 let text = o.org
10 #expect(text.hasPrefix("* Saturn\n["))
11 #expect(text.contains(":EQUIPMENT: 10x50 binoculars"))
12 #expect(text.contains(":SEEING: 3/5"))
13 let back = try #require(Observation(org: text))
14 #expect(back.id == o.id)
15 #expect(back.object == "Saturn")
16 #expect(abs(back.date.timeIntervalSince(when)) < 60)
17 #expect(back.latitude == 41.8781)
18 #expect(back.seeing == 3)
19 #expect(back.notes == o.notes)
20}
21
22@Test func minimalEntryRoundTrips() throws {
23 let o = Observation(object: "M31", date: when, place: "Backyard")
24 let back = try #require(Observation(org: o.org))
25 #expect(back.equipment == "" && back.seeing == nil && back.notes == "")
26}
27
28@Test func folderSaveListDelete() throws {
29 let dir = FileManager.default.temporaryDirectory.appending(path: "obs-\(UUID().uuidString)")
30 defer { try? FileManager.default.removeItem(at: dir) }
31 let log = ObservingLog(folder: dir)
32 var a = Observation(object: "Moon", date: when, place: "Chicago")
33 let b = Observation(object: "Jupiter", date: when.addingTimeInterval(3600), place: "Chicago")
34 try log.save(a)
35 try log.save(b)
36 #expect(log.all().map(\.object) == ["Jupiter", "Moon"])
37 a.notes = "Earthshine."
38 try log.save(a)
39 #expect(log.all().count == 2)
40 #expect(log.all().first { $0.id == a.id }?.notes == "Earthshine.")
41 try log.delete(id: b.id)
42 #expect(log.all().map(\.object) == ["Moon"])
43 #expect(log.fileURL(for: a).lastPathComponent.hasSuffix("-moon.org"))
44}
45
46@Test func equipmentIDsRoundTrip() throws {
47 let o = Observation(object: "M13", date: when, place: "Backyard", equipment: "8-inch Dobsonian, 25 mm Plössl", equipmentIDs: ["scope-1", "ep-2"])
48 #expect(o.org.contains(":EQUIPMENT: 8-inch Dobsonian, 25 mm Plössl\n:EQUIPMENT_IDS: scope-1 ep-2\n"))
49 let back = try #require(Observation(org: o.org))
50 #expect(back.equipment == o.equipment)
51 #expect(back.equipmentIDs == ["scope-1", "ep-2"])
52}
53
54@Test func freeTextEquipmentStillParses() throws {
55 let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:EQUIPMENT: 10x50 binoculars\n:END:\n"
56 let o = try #require(Observation(org: text))
57 #expect(o.equipment == "10x50 binoculars")
58 #expect(o.equipmentIDs.isEmpty)
59}
60
61@Test func equipmentOrgRoundTrip() throws {
62 let scope = Equipment(kind: .telescope, name: "8-inch Dobsonian", aperture: 203, focalLength: 1200, notes: "Collimated in May.")
63 let text = scope.org
64 #expect(text.hasPrefix("* 8-inch Dobsonian\n:PROPERTIES:\n"))
65 #expect(text.contains(":TYPE: telescope\n"))
66 #expect(text.contains(":APERTURE: 203 mm\n"))
67 #expect(text.contains(":FOCAL_LENGTH: 1200 mm\n"))
68 #expect(text.contains(":FOCAL_RATIO: f/5.9\n"))
69 #expect(!text.contains(":RETIRED:"))
70 let back = try #require(Equipment(org: text))
71 #expect(back == scope)
72
73 let binos = Equipment(kind: .binoculars, name: "10x50", aperture: 50, magnification: 10, fieldOfView: 6.5, retired: true)
74 #expect(binos.org.contains(":MAGNIFICATION: 10x\n"))
75 #expect(binos.org.contains(":FIELD_OF_VIEW: 6.5°\n"))
76 #expect(binos.org.contains(":RETIRED: t\n"))
77 #expect(Equipment(org: binos.org) == binos)
78
79 let cam = Equipment(kind: .camera, name: "ZWO", sensor: "IMX585")
80 #expect(Equipment(org: cam.org) == cam)
81}
82
83@Test func inventorySaveListDelete() throws {
84 let file = FileManager.default.temporaryDirectory.appending(path: "eq-\(UUID().uuidString)/Equipment.org")
85 defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) }
86 let inv = EquipmentInventory(file: file)
87 #expect(inv.all().isEmpty)
88 var scope = Equipment(kind: .telescope, name: "Dob", aperture: 203, notes: "Line one.\n\nLine two.")
89 let ep = Equipment(kind: .eyepiece, name: "25 mm Plössl", focalLength: 25, fieldOfView: 52)
90 try inv.save(scope)
91 try inv.save(ep)
92 #expect(inv.all() == [scope, ep])
93 scope.retired = true
94 try inv.save(scope)
95 #expect(inv.all() == [scope, ep])
96 #expect(try String(contentsOf: file, encoding: .utf8).hasPrefix("#+title: Equipment\n"))
97 try inv.delete(id: scope.id)
98 #expect(inv.all() == [ep])
99}
100
101@Test func equipmentUsage() throws {
102 var cal = Calendar(identifier: .gregorian)
103 cal.timeZone = TimeZone(identifier: "UTC")!
104 func day(_ y: Int, _ m: Int, _ d: Int) -> Date { cal.date(from: DateComponents(year: y, month: m, day: d, hour: 3))! }
105 let log = [
106 Observation(object: "Saturn", date: day(2026, 1, 10), place: "", equipmentIDs: ["scope", "ep25"], seeing: 4),
107 Observation(object: "saturn ", date: day(2026, 7, 10), place: "", equipmentIDs: ["scope", "ep25"], seeing: 2),
108 Observation(object: "M31", date: day(2026, 10, 10), place: "", equipmentIDs: ["scope", "ep10"]),
109 Observation(object: "Moon", date: day(2026, 10, 11), place: "", latitude: -33.9, longitude: 18.4, equipmentIDs: ["scope"], seeing: 3),
110 Observation(object: "Jupiter", date: day(2026, 3, 1), place: "", equipmentIDs: ["binos"]),
111 Observation(object: "Mars", date: day(2026, 3, 2), place: "", equipment: "Telescope"),
112 ]
113 let u = EquipmentUsage(of: "scope", in: log, calendar: cal)
114 #expect(u.observations == 4)
115 #expect(u.first == day(2026, 1, 10))
116 #expect(u.last == day(2026, 10, 11))
117 #expect(u.distinctObjects == 3)
118 #expect(u.topObjects.first == .init(key: "Saturn", count: 2))
119 #expect(u.byMonth == [1, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0])
120 #expect(u.bySeason == [.winter: 1, .summer: 1, .autumn: 1, .spring: 1])
121 #expect(u.averageSeeing == 3)
122 #expect(u.usedWith == [.init(key: "ep25", count: 2), .init(key: "ep10", count: 1)])
123
124 let none = EquipmentUsage(of: "camera", in: log, calendar: cal)
125 #expect(none.observations == 0 && none.first == nil && none.averageSeeing == nil && none.usedWith.isEmpty)
126}
127
128@Test func observationKeepsHandAddedProperties() throws {
129 let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:TRANSPARENCY: 4/5\n:PLACE: Chicago\n:CATEGORY: planet\n:BORTLE:\n:END:\n\nRings.\n"
130 var o = try #require(Observation(org: text))
131 o.notes = "Rings and Titan."
132 let out = o.org
133 #expect(out.contains(":PLACE: Chicago\n:TRANSPARENCY: 4/5\n:CATEGORY: planet\n:BORTLE:\n:END:\n"))
134 #expect(Observation(org: out) == o)
135}
136
137@Test func equipmentKeepsHandAddedProperties() throws {
138 let text = "* Dob\n:PROPERTIES:\n:ID: scope-1\n:TYPE: telescope\n:PURCHASED: 2019-05-02\n:APERTURE: 203 mm\n:FOCAL_RATIO: f/5.9\n:SERIAL: X123\n:END:\n"
139 var scope = try #require(Equipment(org: text))
140 scope.focalLength = 1200
141 let out = scope.org
142 #expect(out.contains(":FOCAL_RATIO: f/5.9\n:PURCHASED: 2019-05-02\n:SERIAL: X123\n:END:\n"))
143 #expect(Equipment(org: out) == scope)
144}
145
146@Test func catalogAndOffsetRoundTrip() throws {
147 let o = Observation(object: "M31", date: when, place: "Chicago", catalog: ["M31", "NGC224"], utcOffset: -5 * 3600)
148 let text = o.org
149 #expect(text.hasPrefix("* M31\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n"))
150 #expect(text.contains(":CATALOG: M31 NGC224\n:PLACE: Chicago\n:UTC_OFFSET: -05:00\n"))
151 #expect(Observation(org: text) == o)
152
153 let india = try #require(Observation(org: "* M42\n[2026-10-17 Sat 07:30]\n:PROPERTIES:\n:ID: x\n:UTC_OFFSET: +05:30\n:END:\n"))
154 #expect(india.date == when)
155 #expect(india.utcOffset == 19_800)
156 #expect(india.otherProperties.isEmpty)
157}
158
159@Test func entryWithoutOffsetReadsInDeviceZone() throws {
160 let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:END:\n"
161 let o = try #require(Observation(org: text))
162 #expect(o.utcOffset == nil)
163 #expect(o.date.formatted(Observation.orgDate(.current)) == "2026-10-16 Fri 21:00")
164 #expect(!o.org.contains(":UTC_OFFSET:"))
165}
166
167@Test func saveStampsOffsetAndCatalog() throws {
168 let dir = FileManager.default.temporaryDirectory.appending(path: "obs-\(UUID().uuidString)")
169 defer { try? FileManager.default.removeItem(at: dir) }
170 let log = ObservingLog(folder: dir)
171 let resolver = CatalogResolver(names: ["andromeda galaxy": "M31"])
172
173 try log.save(Observation(id: "a", object: "Messier 31", date: when, place: ""), resolver: resolver)
174 let a = try #require(log.all().first)
175 #expect(a.catalog == ["M31"])
176 #expect(a.utcOffset == TimeZone.current.secondsFromGMT(for: when))
177
178 // A name that doesn't resolve keeps the IDs it was logged with; one that does replaces them.
179 var b = Observation(id: "b", object: "Andromeda core", date: when, place: "", catalog: ["M31"], utcOffset: 3600)
180 try log.save(b, resolver: resolver)
181 b = try #require(log.all().first { $0.id == "b" })
182 #expect(b.catalog == ["M31"] && b.utcOffset == 3600)
183 b.object = "M 33"
184 try log.save(b, resolver: resolver)
185 #expect(log.all().first { $0.id == "b" }?.catalog == ["M33"])
186
187 try log.save(Observation(id: "c", object: "Saturn", date: when, place: ""), resolver: resolver)
188 #expect(log.all().first { $0.id == "c" }?.catalog == [])
189}
190
191@Test func equipmentUsageGroupsByCatalogue() throws {
192 let resolver = CatalogResolver(names: ["andromeda galaxy": "M31"])
193 let log = [
194 Observation(object: "M31", date: when, place: "", equipmentIDs: ["scope"]),
195 Observation(object: "Andromeda Galaxy", date: when, place: "", equipmentIDs: ["scope"]),
196 Observation(object: "Core of Andromeda", date: when, place: "", equipmentIDs: ["scope"], catalog: ["M31"]),
197 Observation(object: "Saturn", date: when, place: "", equipmentIDs: ["scope"]),
198 ]
199 let u = EquipmentUsage(of: "scope", in: log, resolver: resolver)
200 #expect(u.distinctObjects == 2)
201 #expect(u.topObjects == [.init(key: "M31", count: 3), .init(key: "Saturn", count: 1)])
202}