diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..64a9a35 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + + - name: Select Xcode 26 + run: sudo xcode-select -s "$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)" + + - name: Show environment + run: | + xcodebuild -version + xcrun simctl list devices available + + - name: Run unit tests + run: | + xcodebuild test \ + -project SunriseSunset.xcodeproj \ + -scheme SunriseSunset \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -resultBundlePath TestResults.xcresult + + - name: Upload result bundle + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: TestResults.xcresult diff --git a/AGENTS.md b/AGENTS.md index 610cd81..a8290a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,11 @@ scripts/ios-loop-screenshot.sh --name check Raw build: `xcodebuild -project SunriseSunset.xcodeproj -scheme SunriseSunset -destination "platform=iOS Simulator,id=" build` -For anything interactive (taps, assertions, screenshots, logs, seeding app state, verifying UI changes), use the **ios-simulator-loop skill** (`.claude/skills/ios-simulator-loop/SKILL.md`). It documents the app's screens, accessibility quirks, defaults keys, and known simulator failure modes. There is no unit-test suite worth running yet (`SunriseSunsetTests` is an empty template). +Unit tests (Swift Testing, app-hosted in `SunriseSunsetTests`; covers SunLogic/Date extensions/SunPlace): +`xcodebuild test -project SunriseSunset.xcodeproj -scheme SunriseSunset -destination "platform=iOS Simulator,id="` +The `SunriseSunset` scheme is shared (`xcshareddata/xcschemes`) so CI can run it — keep it committed. CI (`.github/workflows/ci.yml`) runs the same command on every push/PR. `SunriseSunsetUITests` is still an empty template and not in the scheme. + +For anything interactive (taps, assertions, screenshots, logs, seeding app state, verifying UI changes), use the **ios-simulator-loop skill** (`.claude/skills/ios-simulator-loop/SKILL.md`). It documents the app's screens, accessibility quirks, defaults keys, and known simulator failure modes. ## Hard-won gotchas diff --git a/README.md b/README.md index 4da65f5..ff80e13 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Solis +[![CI](https://github.com/coffee-cup/solis/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/coffee-cup/solis/actions/workflows/ci.yml) + ![app icon](http://i.imgur.com/wezWMEi.png) A simple iOS app to show you when the sun sets, rises, and in between. diff --git a/SunriseSunset.xcodeproj/project.pbxproj b/SunriseSunset.xcodeproj/project.pbxproj index 44c872f..f38032d 100644 --- a/SunriseSunset.xcodeproj/project.pbxproj +++ b/SunriseSunset.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 21112CEC9025F28135534F7B /* EDSunriseSet.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A0AAE4A6F08C0DDC6F474AC /* EDSunriseSet.m */; }; 230AF26175B6E6B26362968D /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C3314F31CE9712F004D012E /* Styles.swift */; }; 25996D1618113BDB229E641B /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ABC75F23CDF0550304EF92 /* TimelineView.swift */; }; + 2CED5A7A017C8560F1FC5820 /* SunLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E1FC550F22BF6F9896C0808 /* SunLogicTests.swift */; }; 3B56FADAB3F72D3216B7F562 /* LocationSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC69F3849FE0476619A6030 /* LocationSearchView.swift */; }; 439D8C02013EC9EF7E205329 /* LocationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B2205266CEA5B6627F0C1FF /* LocationModel.swift */; }; 48D2BEE4C590A877365129A5 /* SunLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CE885951CEFEC260045BEFB /* SunLocation.swift */; }; @@ -37,7 +38,6 @@ 9C97400A1CFC10F9000A5AFD /* Easing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C9740091CFC10F9000A5AFD /* Easing.swift */; }; 9CB37BEB1CE844400080B5B1 /* SunViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB37BEA1CE844400080B5B1 /* SunViewController.swift */; }; 9CB37BF01CE844400080B5B1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9CB37BEF1CE844400080B5B1 /* Assets.xcassets */; }; - 9CB37BFE1CE844400080B5B1 /* SunriseSunsetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB37BFD1CE844400080B5B1 /* SunriseSunsetTests.swift */; }; 9CB37C091CE844400080B5B1 /* SunriseSunsetUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB37C081CE844400080B5B1 /* SunriseSunsetUITests.swift */; }; 9CBAFF5F1D5198790009444E /* TouchThroughView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CBAFF5E1D5198790009444E /* TouchThroughView.swift */; }; 9CBB09AE1CED6D1000F344C4 /* Sun.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CBB09AD1CED6D1000F344C4 /* Sun.swift */; }; @@ -59,8 +59,10 @@ DA34A5D460BC99BFB3350109 /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7765696F90C41EDE4C0AD8C7 /* SettingsModel.swift */; }; DA9E27A743466059639DD154 /* SunType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C6BCB211D3CA6F00069BC16 /* SunType.swift */; }; DF75EEAD813A951B758723F8 /* SunLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C20A3F81D0FD5D40079F3A5 /* SunLogic.swift */; }; + E206695808FAF1017849DFAA /* SunPlaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CE575763F936969B069E965 /* SunPlaceTests.swift */; }; EAB24A15FED9F76A9E01C843 /* SolisWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2242A66A4072558D25ABC9DE /* SolisWidget.swift */; }; F0EA23BB4999D15E5D3A3CD9 /* TimeFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C236CDE1D169C6900FB9985 /* TimeFormatters.swift */; }; + F389B4709AFBAA446D5EB9F9 /* DateExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A1F502CB945B2DCE88EC0C /* DateExtensionsTests.swift */; }; F7ADFA7018FBB691E72B6390 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242E879322DD2753D25E8EFB /* RootView.swift */; }; /* End PBXBuildFile section */ @@ -106,12 +108,14 @@ 04059EC3ED7F364A9EEABFAC /* InfoMenuView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InfoMenuView.swift; sourceTree = ""; }; 0AC69F3849FE0476619A6030 /* LocationSearchView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationSearchView.swift; sourceTree = ""; }; 160A73A9443501621C6585F0 /* SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 18A1F502CB945B2DCE88EC0C /* DateExtensionsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DateExtensionsTests.swift; sourceTree = ""; }; 1AE6027251CB52A1450C74C9 /* NotificationScheduler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = ""; }; 2242A66A4072558D25ABC9DE /* SolisWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SolisWidget.swift; sourceTree = ""; }; 242E879322DD2753D25E8EFB /* RootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 2B2205266CEA5B6627F0C1FF /* LocationModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationModel.swift; sourceTree = ""; }; 3A0AAE4A6F08C0DDC6F474AC /* EDSunriseSet.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = EDSunriseSet.m; sourceTree = ""; }; 3CD5CC78BCDA3AB2EC6BB260 /* LocationProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationProvider.swift; sourceTree = ""; }; + 3E1FC550F22BF6F9896C0808 /* SunLogicTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SunLogicTests.swift; sourceTree = ""; }; 3E259C8E739316658A5C3327 /* InfoView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = ""; }; 3F6C1433D94FFEE08BCA501A /* SolisWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolisWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4AE7CBA9AB862BFA860F55BA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; @@ -141,7 +145,6 @@ 9CB37BEF1CE844400080B5B1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9CB37BF41CE844400080B5B1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9CB37BF91CE844400080B5B1 /* SunriseSunsetTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SunriseSunsetTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 9CB37BFD1CE844400080B5B1 /* SunriseSunsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SunriseSunsetTests.swift; sourceTree = ""; }; 9CB37BFF1CE844400080B5B1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9CB37C041CE844400080B5B1 /* SunriseSunsetUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SunriseSunsetUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9CB37C081CE844400080B5B1 /* SunriseSunsetUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SunriseSunsetUITests.swift; sourceTree = ""; }; @@ -153,6 +156,7 @@ 9CC427A41D11088100693E32 /* UILabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UILabel.swift; sourceTree = ""; }; 9CDE815B1D03E8710036FD34 /* Muli-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Muli-Light.ttf"; sourceTree = ""; }; 9CDE815C1D03E8710036FD34 /* Muli-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Muli-Regular.ttf"; sourceTree = ""; }; + 9CE575763F936969B069E965 /* SunPlaceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SunPlaceTests.swift; sourceTree = ""; }; 9CE6F8DD1D56E2F300CD5680 /* SunArea.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SunArea.swift; sourceTree = ""; }; 9CE885951CEFEC260045BEFB /* SunLocation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SunLocation.swift; sourceTree = ""; }; CD058FA461B9EFA02E78BDBA /* BackgroundRefresh.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundRefresh.swift; sourceTree = ""; }; @@ -351,8 +355,10 @@ 9CB37BFC1CE844400080B5B1 /* SunriseSunsetTests */ = { isa = PBXGroup; children = ( - 9CB37BFD1CE844400080B5B1 /* SunriseSunsetTests.swift */, 9CB37BFF1CE844400080B5B1 /* Info.plist */, + 3E1FC550F22BF6F9896C0808 /* SunLogicTests.swift */, + 18A1F502CB945B2DCE88EC0C /* DateExtensionsTests.swift */, + 9CE575763F936969B069E965 /* SunPlaceTests.swift */, ); path = SunriseSunsetTests; sourceTree = ""; @@ -611,7 +617,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9CB37BFE1CE844400080B5B1 /* SunriseSunsetTests.swift in Sources */, + 2CED5A7A017C8560F1FC5820 /* SunLogicTests.swift in Sources */, + F389B4709AFBAA446D5EB9F9 /* DateExtensionsTests.swift in Sources */, + E206695808FAF1017849DFAA /* SunPlaceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -822,6 +830,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = io.puddllee.SunriseSunsetTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_SWIFT3_OBJC_INFERENCE = On; SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SunriseSunset.app/SunriseSunset"; @@ -837,6 +846,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = io.puddllee.SunriseSunsetTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_SWIFT3_OBJC_INFERENCE = On; SWIFT_VERSION = 6.0; diff --git a/SunriseSunset.xcodeproj/xcshareddata/xcschemes/SunriseSunset.xcscheme b/SunriseSunset.xcodeproj/xcshareddata/xcschemes/SunriseSunset.xcscheme new file mode 100644 index 0000000..ffa8cc3 --- /dev/null +++ b/SunriseSunset.xcodeproj/xcshareddata/xcschemes/SunriseSunset.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SunriseSunsetTests/DateExtensionsTests.swift b/SunriseSunsetTests/DateExtensionsTests.swift new file mode 100644 index 0000000..bb96751 --- /dev/null +++ b/SunriseSunsetTests/DateExtensionsTests.swift @@ -0,0 +1,49 @@ +// +// DateExtensionsTests.swift +// SunriseSunsetTests +// + +import Foundation +import Testing + +@testable import SunriseSunset + +struct DateExtensionsTests { + + let base = Date(timeIntervalSince1970: 1_000_000) + + @Test func addDays() { + #expect(base.addDays(1) == base.addingTimeInterval(86_400)) + #expect(base.addDays(-2) == base.addingTimeInterval(-172_800)) + } + + @Test func addHours() { + #expect(base.addHours(3) == base.addingTimeInterval(10_800)) + #expect(base.addHours(-1) == base.addingTimeInterval(-3600)) + } + + @Test func comparisons() { + let later = base.addingTimeInterval(1) + + #expect(later.isGreaterThanDate(base)) + #expect(!base.isGreaterThanDate(later)) + #expect(base.isLessThanDate(later)) + #expect(!later.isLessThanDate(base)) + #expect(base.equalToDate(base)) + #expect(!base.equalToDate(later)) + } + + @Test func differenceInMinutesIsAbsoluteAndTruncating() { + let other = base.addingTimeInterval(150) // 2.5 minutes + + #expect(base.getDifferenceInMinutes(other) == 2) + #expect(other.getDifferenceInMinutes(base) == 2) + } + + @Test func differenceInHoursFloors() { + let other = base.addingTimeInterval(119 * 60) + + #expect(base.getDifferenceInHours(other) == 1) + #expect(other.getDifferenceInHours(base) == 1) + } +} diff --git a/SunriseSunsetTests/SunLogicTests.swift b/SunriseSunsetTests/SunLogicTests.swift new file mode 100644 index 0000000..0eb8994 --- /dev/null +++ b/SunriseSunsetTests/SunLogicTests.swift @@ -0,0 +1,125 @@ +// +// SunLogicTests.swift +// SunriseSunsetTests +// + +import CoreLocation +import Foundation +import Testing + +@testable import SunriseSunset + +private let vancouver = CLLocationCoordinate2D(latitude: 49.2827, longitude: -123.1207) +private let vancouverTZ = TimeZone(identifier: "America/Vancouver")! + +private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 12, _ minute: Int = 0, timeZone: TimeZone) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute))! +} + +private func suntime(_ type: SunType, day: SunDay = .today, date: Date, neverHappens: Bool = false) -> Suntime { + let time = Suntime(type: type, day: day) + time.date = date + time.neverHappens = neverHappens + return time +} + +struct SunLogicTests { + + @Test func vancouverWinterTimesMatchReference() { + let noon = date(2026, 1, 15, timeZone: vancouverTZ) + let times = SunLogic.calculateTimesForDate(noon, location: vancouver, timezone: vancouverTZ, day: .today) + + #expect(times.count == 8) + #expect(times.allSatisfy { !$0.neverHappens }) + + // Reference: NOAA solar calculator for 2026-01-15 (PST) + let expectedSunrise = date(2026, 1, 15, 8, 1, timeZone: vancouverTZ) + let expectedSunset = date(2026, 1, 15, 16, 43, timeZone: vancouverTZ) + let tolerance: TimeInterval = 300 + + let sunrise = times.first { $0.type == .sunrise }! + let sunset = times.first { $0.type == .sunset }! + #expect(abs(sunrise.date.timeIntervalSince(expectedSunrise)) < tolerance) + #expect(abs(sunset.date.timeIntervalSince(expectedSunset)) < tolerance) + } + + @Test func vancouverWinterEventsAreOrdered() { + let noon = date(2026, 1, 15, timeZone: vancouverTZ) + let times = SunLogic.calculateTimesForDate(noon, location: vancouver, timezone: vancouverTZ, day: .today) + + let ordered = times.sorted().map(\.type) + #expect(ordered == [.astronomicalDawn, .nauticalDawn, .civilDawn, .sunrise, .sunset, .civilDusk, .nauticalDusk, .astronomicalDusk]) + } + + @Test func midnightSunNeverRisesOrSets() { + let longyearbyen = CLLocationCoordinate2D(latitude: 78.2232, longitude: 15.6267) + let osloTZ = TimeZone(identifier: "Europe/Oslo")! + let noon = date(2026, 6, 15, timeZone: osloTZ) + let times = SunLogic.calculateTimesForDate(noon, location: longyearbyen, timezone: osloTZ, day: .today) + + #expect(times.first { $0.type == .sunrise }!.neverHappens) + #expect(times.first { $0.type == .sunset }!.neverHappens) + } + + @Test func highLatitudeSummerSkipsAstronomicalTwilight() { + // At 49°N near the solstice the sun never reaches 18° below the horizon. + let noon = date(2026, 6, 21, timeZone: vancouverTZ) + let times = SunLogic.calculateTimesForDate(noon, location: vancouver, timezone: vancouverTZ, day: .today) + + #expect(times.first { $0.type == .astronomicalDawn }!.neverHappens) + #expect(times.first { $0.type == .astronomicalDusk }!.neverHappens) + #expect(!times.first { $0.type == .sunrise }!.neverHappens) + #expect(!times.first { $0.type == .sunset }!.neverHappens) + } + + @Test func neverHappensRequiresExact24HourSpan() { + let start = Date(timeIntervalSince1970: 1_000_000) + #expect(SunLogic.neverHappens(start, date2: start.addDays(1))) + #expect(SunLogic.neverHappens(start.addDays(1), date2: start)) + #expect(!SunLogic.neverHappens(start, date2: start.addingTimeInterval(86399))) + #expect(!SunLogic.neverHappens(start, date2: start.addingTimeInterval(86401))) + } + + @Test func getSunTypeReturnsEarliestMatch() { + let early = suntime(.sunrise, date: Date(timeIntervalSince1970: 1000)) + let late = suntime(.sunrise, day: .tomorrow, date: Date(timeIntervalSince1970: 2000)) + let other = suntime(.sunset, date: Date(timeIntervalSince1970: 500)) + + #expect(SunLogic.getSunType([late, other, early], type: .sunrise) === early) + } + + @Test func getSunTypeFiltersNeverHappensAndDay() { + let never = suntime(.sunrise, date: Date(timeIntervalSince1970: 1000), neverHappens: true) + let tomorrow = suntime(.sunrise, day: .tomorrow, date: Date(timeIntervalSince1970: 2000)) + + #expect(SunLogic.getSunType([never], type: .sunrise) == nil) + #expect(SunLogic.getSunType([never, tomorrow], type: .sunrise, day: .today) == nil) + #expect(SunLogic.getSunType([never, tomorrow], type: .sunrise, day: .tomorrow) === tomorrow) + } + + @Test func getFirstSunTypeRespectsTypePriorityOverDate() { + let dawn = suntime(.civilDawn, date: Date(timeIntervalSince1970: 5000)) + let sunrise = suntime(.sunrise, date: Date(timeIntervalSince1970: 1000)) + + #expect(SunLogic.getFirstSunType([dawn, sunrise], sunTypes: [.civilDawn, .sunrise]) === dawn) + } + + @Test func futureTimesDropsPastTimes() { + let past = suntime(.sunset, date: Date().addingTimeInterval(-3600)) + let future = suntime(.sunrise, date: Date().addingTimeInterval(3600)) + + let result = SunLogic.futureTimes([past, future]) + #expect(result.count == 1) + #expect(result.first === future) + } + + @Test func suntimesCompareByDate() { + let earlier = suntime(.sunrise, date: Date(timeIntervalSince1970: 1000)) + let later = suntime(.sunset, date: Date(timeIntervalSince1970: 2000)) + + #expect(earlier < later) + #expect(!(later < earlier)) + } +} diff --git a/SunriseSunsetTests/SunPlaceTests.swift b/SunriseSunsetTests/SunPlaceTests.swift new file mode 100644 index 0000000..8542ce6 --- /dev/null +++ b/SunriseSunsetTests/SunPlaceTests.swift @@ -0,0 +1,73 @@ +// +// SunPlaceTests.swift +// SunriseSunsetTests +// + +import CoreLocation +import Foundation +import Testing + +@testable import SunriseSunset + +struct SunPlaceTests { + + @Test func roundTripWithTimeZoneOffset() { + let place = SunPlace( + primary: "Vancouver", + secondary: "BC, Canada", + location: CLLocationCoordinate2D(latitude: 49.2827, longitude: -123.1207), + placeID: "abc123", + timeZoneOffset: -28800, + isNotification: true + ) + + let parsed = SunPlace.sunPlaceFromString(place.toString!) + + #expect(parsed != nil) + #expect(parsed?.primary == "Vancouver") + #expect(parsed?.secondary == "BC, Canada") + #expect(parsed?.location?.latitude == 49.2827) + #expect(parsed?.location?.longitude == -123.1207) + #expect(parsed?.placeID == "abc123") + #expect(parsed?.timeZoneOffset == -28800) + #expect(parsed?.isNotification == true) + } + + @Test func toStringRequiresLocation() { + let place = SunPlace(primary: "Vancouver", secondary: "BC", placeID: "abc123") + + #expect(place.toString == nil) + } + + @Test func parseRejectsTooFewFields() { + #expect(SunPlace.sunPlaceFromString("a|b|1.0|2.0") == nil) + #expect(SunPlace.sunPlaceFromString("") == nil) + } + + @Test func nilTimeZoneOffsetRoundTripIsLossy() { + // A nil tzOffset serializes to an empty field that split(separator:) drops, + // shifting isNotification into the tzOffset slot. Pins current behaviour. + let place = SunPlace( + primary: "Vancouver", + secondary: "BC", + location: CLLocationCoordinate2D(latitude: 49.0, longitude: -123.0), + placeID: "abc123", + timeZoneOffset: nil, + isNotification: true + ) + + let parsed = SunPlace.sunPlaceFromString(place.toString!) + + #expect(parsed?.timeZoneOffset == nil) + #expect(parsed?.isNotification == false) + } + + @Test func equalityComparesPlaceIDOnly() { + let a = SunPlace(primary: "A", secondary: "x", placeID: "same") + let b = SunPlace(primary: "B", secondary: "y", placeID: "same") + let c = SunPlace(primary: "A", secondary: "x", placeID: "different") + + #expect(a == b) + #expect(a != c) + } +} diff --git a/SunriseSunsetTests/SunriseSunsetTests.swift b/SunriseSunsetTests/SunriseSunsetTests.swift deleted file mode 100644 index 89ff8e2..0000000 --- a/SunriseSunsetTests/SunriseSunsetTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// SunriseSunsetTests.swift -// SunriseSunsetTests -// -// Created by Jake Runzer on 2016-05-14. -// Copyright © 2016 Puddllee. All rights reserved. -// - -import XCTest -@testable import SunriseSunset - -class SunriseSunsetTests: XCTestCase { - - override func setUp() { - super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - super.tearDown() - } - - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testPerformanceExample() { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -}