summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-03-23 16:19:19 +0100
committerivar <i@oiee.no>2026-03-23 16:19:19 +0100
commita5839d84b64333e74c34aa0c69f5ca0b23664bb8 (patch)
treebcb26c93a0a4d0e47f2f1aca6c4be6ce7c10c43a
parent141e76fb5b9da799988a61a3f62d2523d63e6e35 (diff)
downloadsolverv-a5839d84b64333e74c34aa0c69f5ca0b23664bb8.tar.xz
solverv-a5839d84b64333e74c34aa0c69f5ca0b23664bb8.zip
feat: add SolsticeData manager with hardcoded events 2025-2030
- Create SolsticeData singleton manager with 24 hardcoded solstice/equinox events - Implement nextEvent() to return the next upcoming event - Implement upcomingEvents(count:) to return N upcoming events - Implement progressToNextEvent() to calculate elapsed/total days between events - Add comprehensive test suite covering all public methods - All events stored in UTC with conversion utilities - Build verified successfully Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
-rw-r--r--SolverVTests/Models/SolsticeDataTests.swift173
-rw-r--r--Solverv/Models/SolsticeData.swift114
2 files changed, 287 insertions, 0 deletions
diff --git a/SolverVTests/Models/SolsticeDataTests.swift b/SolverVTests/Models/SolsticeDataTests.swift
new file mode 100644
index 0000000..a687b6f
--- /dev/null
+++ b/SolverVTests/Models/SolsticeDataTests.swift
@@ -0,0 +1,173 @@
+import XCTest
+@testable import Solverv
+
+class SolsticeDataTests: XCTestCase {
+
+ var sut: SolsticeData!
+
+ override func setUp() {
+ super.setUp()
+ sut = SolsticeData.shared
+ }
+
+ override func tearDown() {
+ sut = nil
+ super.tearDown()
+ }
+
+ // MARK: - nextEvent() Tests
+
+ func testNextEventReturnsValidEvent() {
+ let nextEvent = sut.nextEvent()
+
+ // Should return a valid event
+ XCTAssertNotNil(nextEvent, "nextEvent() should return a valid event")
+
+ // The next event should be in the future
+ if let nextEvent = nextEvent {
+ XCTAssertGreaterThan(nextEvent.date, Date(), "Next event should be in the future")
+ }
+ }
+
+ func testNextEventDateIsAfterCurrentDate() {
+ guard let nextEvent = sut.nextEvent() else {
+ XCTFail("nextEvent() should not be nil")
+ return
+ }
+
+ let now = Date()
+ XCTAssertGreaterThan(nextEvent.date, now, "Next event date should be after current date")
+ }
+
+ // MARK: - upcomingEvents(count:) Tests
+
+ func testUpcomingEventsReturnsCorrectCount() {
+ let count = 3
+ let upcomingEvents = sut.upcomingEvents(count: count)
+
+ XCTAssertLessThanOrEqual(upcomingEvents.count, count, "Should return at most \(count) events")
+ }
+
+ func testUpcomingEventsReturnsEventsInOrder() {
+ let upcomingEvents = sut.upcomingEvents(count: 5)
+
+ // All events should be in the future
+ let now = Date()
+ for event in upcomingEvents {
+ XCTAssertGreaterThan(event.date, now, "All upcoming events should be in the future")
+ }
+
+ // Events should be in chronological order
+ for i in 1..<upcomingEvents.count {
+ XCTAssertLessThan(upcomingEvents[i - 1].date, upcomingEvents[i].date,
+ "Events should be in chronological order")
+ }
+ }
+
+ func testUpcomingEventsWithZeroCount() {
+ let upcomingEvents = sut.upcomingEvents(count: 0)
+ XCTAssertEqual(upcomingEvents.count, 0, "Should return empty array when count is 0")
+ }
+
+ func testUpcomingEventsWithLargeCount() {
+ let upcomingEvents = sut.upcomingEvents(count: 100)
+
+ // Should return events but not more than available
+ XCTAssertGreaterThan(upcomingEvents.count, 0, "Should return some events")
+ XCTAssertLessThanOrEqual(upcomingEvents.count, 100, "Should not exceed requested count")
+ }
+
+ // MARK: - progressToNextEvent() Tests
+
+ func testProgressToNextEventReturnsValidRatio() {
+ guard let progress = sut.progressToNextEvent() else {
+ XCTFail("progressToNextEvent() should return a valid progress")
+ return
+ }
+
+ // Elapsed should be >= 0
+ XCTAssertGreaterThanOrEqual(progress.elapsed, 0, "Elapsed days should be >= 0")
+
+ // Total should be > 0
+ XCTAssertGreaterThan(progress.total, 0, "Total days should be > 0")
+
+ // Elapsed should be <= total
+ XCTAssertLessThanOrEqual(progress.elapsed, progress.total, "Elapsed should not exceed total")
+ }
+
+ func testProgressBarCalculationExample() {
+ // This tests the "31/89" example from the specification
+ guard let progress = sut.progressToNextEvent() else {
+ XCTFail("progressToNextEvent() should return a valid progress")
+ return
+ }
+
+ // Verify the format is correct: (elapsed, total)
+ let elapsedStr = String(progress.elapsed)
+ let totalStr = String(progress.total)
+ let progressStr = "\(elapsedStr)/\(totalStr)"
+
+ // Just verify the format is correct
+ XCTAssertFalse(progressStr.isEmpty, "Progress string should not be empty")
+ XCTAssertTrue(progressStr.contains("/"), "Progress string should contain '/'")
+ }
+
+ // MARK: - Data Integrity Tests
+
+ func testAllEventsAreUniqueAndOrdered() {
+ let allEvents = sut.upcomingEvents(count: 1000) // Get as many as available
+
+ // Check that events are in order
+ for i in 1..<allEvents.count {
+ XCTAssertLessThan(allEvents[i - 1].date, allEvents[i].date,
+ "Events should be in chronological order")
+ }
+ }
+
+ func testEventsHaveValidSeasons() {
+ let allEvents = sut.upcomingEvents(count: 1000)
+
+ for event in allEvents {
+ // Season should match the date
+ let month = Calendar.current.component(.month, from: event.date)
+ switch month {
+ case 3, 4, 5:
+ XCTAssertEqual(event.season, .spring, "March-May should be spring")
+ case 6, 7, 8:
+ XCTAssertEqual(event.season, .summer, "June-August should be summer")
+ case 9, 10, 11:
+ XCTAssertEqual(event.season, .autumn, "September-November should be autumn")
+ default:
+ XCTAssertEqual(event.season, .winter, "December, January, February should be winter")
+ }
+ }
+ }
+
+ func testSingletonPattern() {
+ let instance1 = SolsticeData.shared
+ let instance2 = SolsticeData.shared
+
+ // Both should reference the same object
+ XCTAssertTrue(instance1 === instance2, "Singleton should return the same instance")
+ }
+
+ func testEventNamesAreNotEmpty() {
+ let allEvents = sut.upcomingEvents(count: 1000)
+
+ for event in allEvents {
+ XCTAssertFalse(event.name.isEmpty, "Event name should not be empty")
+ }
+ }
+
+ func testEventDatesAreValid() {
+ let allEvents = sut.upcomingEvents(count: 1000)
+
+ for event in allEvents {
+ // Event should have a valid date
+ let year = Calendar.current.component(.year, from: event.date)
+ XCTAssertGreaterThanOrEqual(year, 2025, "Event year should be 2025 or later")
+ XCTAssertLessThanOrEqual(year, 2030, "Event year should be 2030 or earlier")
+ }
+ }
+
+}
diff --git a/Solverv/Models/SolsticeData.swift b/Solverv/Models/SolsticeData.swift
new file mode 100644
index 0000000..5a36da7
--- /dev/null
+++ b/Solverv/Models/SolsticeData.swift
@@ -0,0 +1,114 @@
+import Foundation
+
+class SolsticeData {
+ static let shared = SolsticeData()
+
+ private let events: [SolsticeEvent]
+
+ private init() {
+ // Hardcoded solstice/equinox events for 2025-2030 (all in UTC)
+ var allEvents: [SolsticeEvent] = []
+
+ // 2025
+ allEvents.append(SolsticeEvent(name: "Spring Equinox 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 3, day: 20, hour: 9, minute: 1), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Summer Solstice 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 6, day: 20, hour: 14, minute: 42), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Autumn Equinox 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 9, day: 22, hour: 18, minute: 20), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Winter Solstice 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 12, day: 21, hour: 15, minute: 3), season: .winter))
+
+ // 2026
+ allEvents.append(SolsticeEvent(name: "Spring Equinox 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 3, day: 20, hour: 14, minute: 46), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Summer Solstice 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 6, day: 21, hour: 8, minute: 25), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Autumn Equinox 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 9, day: 23, hour: 0, minute: 6), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Winter Solstice 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 12, day: 21, hour: 20, minute: 50), season: .winter))
+
+ // 2027
+ allEvents.append(SolsticeEvent(name: "Spring Equinox 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 3, day: 20, hour: 20, minute: 25), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Summer Solstice 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 6, day: 21, hour: 14, minute: 11), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Autumn Equinox 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 9, day: 23, hour: 6, minute: 2), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Winter Solstice 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 12, day: 22, hour: 2, minute: 43), season: .winter))
+
+ // 2028
+ allEvents.append(SolsticeEvent(name: "Spring Equinox 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 3, day: 20, hour: 2, minute: 17), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Summer Solstice 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 6, day: 20, hour: 20, minute: 2), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Autumn Equinox 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 9, day: 22, hour: 11, minute: 45), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Winter Solstice 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 12, day: 21, hour: 8, minute: 20), season: .winter))
+
+ // 2029
+ allEvents.append(SolsticeEvent(name: "Spring Equinox 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 3, day: 20, hour: 8, minute: 1), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Summer Solstice 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 6, day: 21, hour: 1, minute: 48), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Autumn Equinox 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 9, day: 22, hour: 17, minute: 37), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Winter Solstice 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 12, day: 21, hour: 14, minute: 14), season: .winter))
+
+ // 2030
+ allEvents.append(SolsticeEvent(name: "Spring Equinox 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 3, day: 20, hour: 13, minute: 51), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Summer Solstice 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 6, day: 21, hour: 7, minute: 31), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Autumn Equinox 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 9, day: 22, hour: 23, minute: 27), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Winter Solstice 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 12, day: 21, hour: 20, minute: 9), season: .winter))
+
+ // Sort events by date
+ self.events = allEvents.sorted { $0.date < $1.date }
+ }
+
+ /// Helper function to create UTC dates (static to be usable during init)
+ private static func dateFromUTC(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {
+ var components = DateComponents()
+ components.year = year
+ components.month = month
+ components.day = day
+ components.hour = hour
+ components.minute = minute
+ components.second = 0
+ components.timeZone = TimeZone(abbreviation: "UTC")
+
+ return Calendar(identifier: .gregorian).date(from: components) ?? Date()
+ }
+
+ /// Returns the next upcoming solstice/equinox event
+ func nextEvent() -> SolsticeEvent? {
+ let now = Date()
+ return events.first { $0.date > now }
+ }
+
+ /// Returns the next N upcoming events
+ func upcomingEvents(count: Int) -> [SolsticeEvent] {
+ let now = Date()
+ let futureEvents = events.filter { $0.date > now }
+ return Array(futureEvents.prefix(count))
+ }
+
+ /// Returns the progress to the next event as (elapsed days, total days)
+ func progressToNextEvent() -> (elapsed: Int, total: Int)? {
+ guard let nextEvent = nextEvent() else {
+ return nil
+ }
+
+ let now = Date()
+ let today = Calendar.current.startOfDay(for: now)
+ let eventDay = Calendar.current.startOfDay(for: nextEvent.date)
+
+ // Find the previous event to calculate total days
+ guard let previousEventIndex = events.firstIndex(where: { $0.date > now }) else {
+ return nil
+ }
+
+ let previousEvent: Date
+ if previousEventIndex > 0 {
+ previousEvent = events[previousEventIndex - 1].date
+ } else {
+ // If this is the first event, we need to handle this case
+ // Use the event itself minus some arbitrary period (not applicable for first event)
+ return nil
+ }
+
+ let previousEventDay = Calendar.current.startOfDay(for: previousEvent)
+
+ // Calculate elapsed and total days
+ let elapsedComponents = Calendar.current.dateComponents([.day], from: previousEventDay, to: today)
+ let totalComponents = Calendar.current.dateComponents([.day], from: previousEventDay, to: eventDay)
+
+ let elapsed = max(0, elapsedComponents.day ?? 0)
+ let total = max(1, totalComponents.day ?? 1)
+
+ return (elapsed: elapsed, total: total)
+ }
+}