Packages/MoonriseKit/Tests/AnswersTests/AnswersTests.swift
183 lines · 9914 bytes
17 symbols in this file
1import Foundation
2import Testing
3@testable import Answers
4@testable import Feeds
5import Library
6@testable import SkyEngine
7
8private let chicagoPlace = Place(latitude: 41.878, longitude: -87.630, name: "Your location", kind: .located)
9private let central = TimeZone(identifier: "America/Chicago")!
10private let gb = Locale(identifier: "en_GB")
11/// 18:00 CDT, 18 October 2026.
12private let evening = Date(timeIntervalSince1970: 1_792_364_400)
13
14@Test func placeRoundsAndRoundTrips() throws {
15 let p = Place(latitude: 41.8781, longitude: -87.6298, name: "Your location", kind: .located)
16 #expect(p.latitude == 41.9)
17 #expect(p.longitude == -87.6)
18 let defaults = try #require(UserDefaults(suiteName: "AnswersTests.place"))
19 defaults.removePersistentDomain(forName: "AnswersTests.place")
20 #expect(Place.saved(in: defaults) == nil)
21 p.save(to: defaults)
22 #expect(Place.saved(in: defaults) == p)
23}
24
25@Test func placeNotes() {
26 #expect(chicagoPlace.note == nil)
27 #expect(Place(latitude: 52.52, longitude: 13.405, name: "Berlin", kind: .city).note == "For Berlin.")
28 #expect(Place.fallback.note == "For Chicago; open Moonrise to set your location.")
29 #expect(Place.fallback.label == "Chicago")
30 #expect(chicagoPlace.label == "Your location")
31}
32
33@Test func compass() {
34 #expect(Compass.point(0) == "N")
35 #expect(Compass.point(135) == "SE")
36 #expect(Compass.point(350) == "N")
37 #expect(Compass.name(240) == "southwest")
38 #expect(Compass.name(270) == "west")
39}
40
41@Test func moonAnswer() throws {
42 let a = try MoonAnswer(at: evening, locale: gb, timeZone: central)
43 let angle = try SkyEngine.moonPhase(at: evening)
44 #expect(a.phaseName == DailySummary.phaseName(angle))
45 #expect(a.waxing == (angle < 180))
46 let lit = Int((try SkyEngine.illumination(.moon, at: evening).phaseFraction * 100).rounded())
47 #expect(a.spoken.hasPrefix("\(a.phaseName), \(lit)% lit. \(MoonAnswer.quarterName(a.nextQuarter)) "))
48 let time = a.nextQuarterDate.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: gb, timeZone: central))
49 #expect(a.spoken.hasSuffix(" at \(time)."))
50 #expect(a.rows.count == 3)
51}
52
53@Test func spokenDays() {
54 let p = Phrasing(locale: gb, timeZone: central)
55 #expect(p.when(evening.addingTimeInterval(3600), from: evening) == "today at 19:00")
56 #expect(p.when(evening.addingTimeInterval(12 * 3600), from: evening) == "tomorrow at \(p.time(evening.addingTimeInterval(12 * 3600)))")
57 // Tuesday 20 October.
58 #expect(p.when(evening.addingTimeInterval(48 * 3600), from: evening) == "Tuesday at 18:00")
59 #expect(p.when(evening.addingTimeInterval(10 * 86_400), from: evening) == "on 28 October at 18:00")
60}
61
62@Test func tonightAnswer() throws {
63 let a = try TonightAnswer(at: evening, place: chicagoPlace, locale: gb, timeZone: central)
64 let t = try Tonight(at: evening, from: chicagoPlace.observer)
65 #expect(a.planets.map(\.name) == t.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized })
66 #expect(a.spoken.contains("Sunset 18:"))
67 #expect(a.heading == "Tonight · Your location")
68 #expect(a.rows.count <= 4)
69 #expect(a.rows.last?.label == "Sun")
70 if let first = a.planets.first { #expect(a.spoken.hasPrefix("Up tonight: \(first.name)")) }
71
72 let berlin = Place(latitude: 52.52, longitude: 13.405, name: "Berlin", kind: .city)
73 let b = try TonightAnswer(at: evening, place: berlin, locale: gb, timeZone: central)
74 #expect(b.spoken.hasSuffix(" For Berlin."))
75}
76
77private func pass(_ name: String, rise: TimeInterval, alt: Double, visible: Bool) -> SatellitePass {
78 SatellitePass(name: name, rise: evening.addingTimeInterval(rise), set: evening.addingTimeInterval(rise + 360), peak: evening.addingTimeInterval(rise + 180),
79 maxAltitude: alt, riseAzimuth: 240, setAzimuth: 60, visible: visible, featured: true)
80}
81
82@Test func passAnswerPrefersVisible() {
83 let dull = pass("ISS (ZARYA)", rise: 1800, alt: 20, visible: false)
84 let bright = pass("ISS (ZARYA)", rise: 5400, alt: 54.4, visible: true)
85 let a = PassAnswer(satellite: .iss, passes: [dull, bright], at: evening, place: chicagoPlace, locale: gb, timeZone: central)
86 #expect(a.pass == bright)
87 #expect(a.spoken == "The ISS rises today at 19:30 in the southwest, 54° high. Visible to the eye.")
88 #expect(a.rows.count == 4)
89 #expect(!a.rows[0].alert)
90}
91
92@Test func passAnswerWithoutVisiblePass() {
93 let dull = pass("HST", rise: 40_000, alt: 20, visible: false)
94 let a = PassAnswer(satellite: .hubble, passes: [dull], at: evening, place: Place.fallback, locale: gb, timeZone: central)
95 let time = Phrasing(locale: gb, timeZone: central).time(dull.rise)
96 #expect(a.spoken == "No visible Hubble passes in the next three days. The next one rises tomorrow at \(time) in the southwest, 20° high. For Chicago; open Moonrise to set your location.")
97 let none = PassAnswer(satellite: .tiangong, passes: [], at: evening, place: chicagoPlace, locale: gb, timeZone: central)
98 #expect(none.pass == nil)
99 #expect(none.spoken == "No Tiangong passes above 10° in the next three days.")
100}
101
102@Test func passSoonIsAnAlert() {
103 let a = PassAnswer(satellite: .iss, passes: [pass("ISS (ZARYA)", rise: 600, alt: 40, visible: true)], at: evening, place: chicagoPlace, locale: gb, timeZone: central)
104 #expect(a.rows[0].alert)
105}
106
107@Test func satelliteIDs() {
108 #expect(Satellite.allCases.map(\.rawValue) == [25544, 48274, 20580])
109 #expect(Set(Satellite.allCases.map(\.rawValue)) == OrbitalElements.featuredIDs)
110}
111
112private func launches(_ json: String) throws -> [Launch] {
113 try LaunchFeed.decode(Data(#"{"fetchedAt":null,"launches":[\#(json)]}"#.utf8)).launches
114}
115
116private let bandwagon = #"""
117{"id":"a","name":"Falcon 9 Block 5 | Bandwagon 5","net":"2026-10-19T10:30:00Z","status":"Go","statusName":"Go for Launch","provider":"SpaceX",
118 "rocket":"Falcon 9 Block 5","mission":"Bandwagon 5","pad":"Space Launch Complex 40","location":"Cape Canaveral SFS, FL, USA"}
119"""#
120
121@Test func launchAnswer() throws {
122 let done = #"{"id":"b","name":"Electron | X","net":"2026-10-18T22:00:00Z","status":"Success","statusName":"Launch Successful","provider":"Rocket Lab","rocket":"Electron","pad":"LC-1B","location":"Mahia"}"#
123 let a = LaunchAnswer(launches: try launches(done + "," + bandwagon), at: evening, locale: gb, timeZone: central)
124 #expect(a.launch?.id == "a")
125 #expect(a.spoken == "Falcon 9 Block 5, Bandwagon 5, from Cape Canaveral SFS in 11 hours, 30 minutes. Status: Go for Launch.")
126 #expect(a.rows.map(\.label) == ["Rocket", "Mission", "From", "Launch"])
127 #expect(!a.rows[3].alert)
128
129 let hold = bandwagon.replacingOccurrences(of: #""status":"Go","statusName":"Go for Launch""#, with: #""status":"Hold","statusName":"On Hold""#)
130 #expect(LaunchAnswer(launches: try launches(hold), at: evening, locale: gb, timeZone: central).rows[3].alert)
131 #expect(LaunchAnswer(launches: [], at: evening, locale: gb, timeZone: central).spoken == "No upcoming launches are scheduled.")
132}
133
134@Test func auroraAnswer() {
135 let quiet = SpaceWeather(kpNow: 3.33, observedAt: evening, kpNext24h: 4.67)
136 let a = AuroraAnswer(weather: quiet, place: chicagoPlace, locale: gb, timeZone: central)
137 #expect(!a.possible)
138 #expect(a.spoken == "Kp 3.3, quiet. Up to Kp 4.7 in the next 24 hours. Aurora unlikely here. You'd need about Kp 5.")
139 #expect(a.footnote == "NOAA SWPC · 18:00")
140
141 let storm = SpaceWeather(kpNow: 6, observedAt: evening, kpNext24h: nil)
142 let s = AuroraAnswer(weather: storm, place: chicagoPlace, locale: gb, timeZone: central)
143 #expect(s.possible)
144 #expect(s.spoken == "Kp 6.0, G2 storm. Aurora possible low on the northern horizon.")
145 #expect(s.rows[0].alert)
146
147 let hobart = Place(latitude: -42.88, longitude: 147.33, name: "Hobart", kind: .city)
148 #expect(AuroraAnswer(weather: storm, place: hobart, locale: gb, timeZone: central).spoken.contains("southern horizon"))
149
150 let quito = Place(latitude: -0.18, longitude: -78.47, name: "Quito", kind: .city)
151 #expect(AuroraAnswer(weather: quiet, place: quito, locale: gb, timeZone: central).spoken.contains("Aurora is out of reach this far from the poles."))
152}
153
154@Test func probeAnswer() throws {
155 let a = try ProbeAnswer(probe: .voyager1, at: evening, locale: gb)
156 // Voyager 1 is about 170 AU out in late 2026.
157 #expect(a.fromEarthKm > 24e9 && a.fromEarthKm < 26.5e9, "\(a.fromEarthKm)")
158 #expect(abs(a.lightTime - a.fromEarthKm / 299_792.458) < 1)
159 #expect(a.spoken.hasPrefix("Voyager 1 is 2"))
160 #expect(a.spoken.contains(" billion km from Earth. Its signal takes 2"))
161 #expect(!a.spoken.contains("prediction"))
162 #expect(try ProbeAnswer(probe: .pioneer10, at: evening, locale: gb).spoken.hasSuffix("Contact was lost, so this is a prediction."))
163 #expect(Probe.allCases.count == 5)
164}
165
166@Test func lookUpAnswer() throws {
167 let a = LookUpAnswer(page: try #require(Library.page(id: "ori")))
168 #expect(a.spoken.hasPrefix("Orion: The Hunter. Abbreviation: Ori."))
169 #expect(a.rows.count == 3)
170 let m1 = LookUpAnswer(page: try #require(Library.page(id: "m1")))
171 #expect(m1.spoken.hasPrefix("M1: Crab Nebula, supernova remnant in Taurus."))
172}
173
174@Test func skyTargets() throws {
175 #expect(try #require(Library.page(id: "ori")).skyTarget == Tour.Target(constellation: "Ori"))
176 #expect(try #require(Library.page(body: "jupiter")).skyTarget == Tour.Target(body: "jupiter"))
177 #expect(try #require(Library.page(body: "moon")).skyTarget == Tour.Target(body: "moon"))
178 #expect(try #require(Library.page(id: "sirius")).skyTarget?.hr != nil)
179 #expect(try #require(Library.page(id: "sun")).skyTarget == nil)
180 #expect(try #require(Library.page(body: "pluto")).skyTarget == nil)
181 #expect(try #require(Library.page(id: "m42")).skyTarget == nil)
182 #expect(Library.pages.filter { $0.skyTarget != nil }.count == 88 + 50 + 8)
183}