krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Packages/MoonriseKit/Tests/AnswersTests/AnswersTests.swift

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/Packages/MoonriseKit/Tests/AnswersTests/AnswersTests.swift history · blame · raw

183 lines · 9914 bytes

  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}