diff --git a/KosherCocoa.xcodeproj/project.pbxproj b/KosherCocoa.xcodeproj/project.pbxproj index f5e3ab3..f460367 100644 --- a/KosherCocoa.xcodeproj/project.pbxproj +++ b/KosherCocoa.xcodeproj/project.pbxproj @@ -75,12 +75,17 @@ 4618EE1C1BF4F9F900C33159 /* NSDateComponents+AllComponents.m in Sources */ = {isa = PBXBuildFile; fileRef = 466A3ACE17CAF3B900056F16 /* NSDateComponents+AllComponents.m */; }; 469066561F4A7090009E5D46 /* KosherCocoa.podspec in Resources */ = {isa = PBXBuildFile; fileRef = 469066551F4A7090009E5D46 /* KosherCocoa.podspec */; }; 46DB49691D9839F200F3A576 /* KosherCocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4618ECE51BF449EF00C33159 /* KosherCocoa.framework */; }; - 5D435CEA2A3C34CB00F66AE0 /* KCAstronomicalCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D435CE92A3C34CB00F66AE0 /* KCAstronomicalCalculatorTests.swift */; }; + 5D435CEA2A3C34CB00F66AE0 /* KCNOAACalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D435CE92A3C34CB00F66AE0 /* KCNOAACalculatorTests.swift */; }; 5D435CEC2A3C352500F66AE0 /* KCNOAACalculator.h in Headers */ = {isa = PBXBuildFile; fileRef = 5D435CEB2A3C352500F66AE0 /* KCNOAACalculator.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5D435CEF2A3C35B000F66AE0 /* KCNOAACalculator.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D435CEE2A3C35B000F66AE0 /* KCNOAACalculator.m */; }; 5D435CF22A3C369900F66AE0 /* KCAstronomicalCalculator.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D435CF12A3C369900F66AE0 /* KCAstronomicalCalculator.m */; }; 5D435CF42A3C389C00F66AE0 /* Screenshot 2023-06-16 at 2.27.26 AM.png in Resources */ = {isa = PBXBuildFile; fileRef = 5D435CF32A3C389B00F66AE0 /* Screenshot 2023-06-16 at 2.27.26 AM.png */; }; F52587431ECD019A009E623C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F52587411ECD019A009E623C /* Localizable.strings */; }; + F5C6076F2B3F0CD100A1E94C /* KCAstronomicalCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C6076E2B3F0CD100A1E94C /* KCAstronomicalCalculatorTests.swift */; }; + F5C607722B3F13A100A1E94C /* SunriseSunsetLakewoodNOAA.csv in Resources */ = {isa = PBXBuildFile; fileRef = F5C607712B3F13A100A1E94C /* SunriseSunsetLakewoodNOAA.csv */; }; + F5C607772B3F5A4E00A1E94C /* DynamicTestCase.m in Sources */ = {isa = PBXBuildFile; fileRef = F5C607762B3F5A4E00A1E94C /* DynamicTestCase.m */; }; + F5C607792B3F5BE600A1E94C /* TestLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C607782B3F5BE600A1E94C /* TestLoader.swift */; }; + F5C607B72B4226BA00A1E94C /* DynamicNOAACalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C607B62B4226BA00A1E94C /* DynamicNOAACalculatorTests.swift */; }; F5F902FA1FC53B17003FE90D /* KosherCocoaMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46DB495A1D9837D100F3A576 /* KosherCocoaMetadataTests.swift */; }; F5F902FC1FC53C01003FE90D /* KCJewishCalendarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F902FB1FC53C01003FE90D /* KCJewishCalendarTests.swift */; }; /* End PBXBuildFile section */ @@ -174,12 +179,19 @@ 46DB495A1D9837D100F3A576 /* KosherCocoaMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KosherCocoaMetadataTests.swift; sourceTree = ""; }; 46DB495C1D9837D100F3A576 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 46DB49641D9839F100F3A576 /* KosherCocoaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KosherCocoaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 5D435CE92A3C34CB00F66AE0 /* KCAstronomicalCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KCAstronomicalCalculatorTests.swift; sourceTree = ""; }; + 5D435CE92A3C34CB00F66AE0 /* KCNOAACalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KCNOAACalculatorTests.swift; sourceTree = ""; }; 5D435CEB2A3C352500F66AE0 /* KCNOAACalculator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KCNOAACalculator.h; sourceTree = ""; }; 5D435CEE2A3C35B000F66AE0 /* KCNOAACalculator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KCNOAACalculator.m; sourceTree = ""; }; 5D435CF12A3C369900F66AE0 /* KCAstronomicalCalculator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KCAstronomicalCalculator.m; sourceTree = ""; }; 5D435CF32A3C389B00F66AE0 /* Screenshot 2023-06-16 at 2.27.26 AM.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Screenshot 2023-06-16 at 2.27.26 AM.png"; sourceTree = ""; }; F52587421ECD019A009E623C /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = Localizations/en.lproj/Localizable.strings; sourceTree = ""; }; + F5C6076E2B3F0CD100A1E94C /* KCAstronomicalCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KCAstronomicalCalculatorTests.swift; sourceTree = ""; }; + F5C607712B3F13A100A1E94C /* SunriseSunsetLakewoodNOAA.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = SunriseSunsetLakewoodNOAA.csv; sourceTree = ""; }; + F5C607752B3F5A4D00A1E94C /* KosherCocoaTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "KosherCocoaTests-Bridging-Header.h"; sourceTree = ""; }; + F5C607762B3F5A4E00A1E94C /* DynamicTestCase.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DynamicTestCase.m; sourceTree = ""; }; + F5C607782B3F5BE600A1E94C /* TestLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestLoader.swift; sourceTree = ""; }; + F5C607B52B42158500A1E94C /* DynamicTestCase.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DynamicTestCase.h; sourceTree = ""; }; + F5C607B62B4226BA00A1E94C /* DynamicNOAACalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicNOAACalculatorTests.swift; sourceTree = ""; }; F5F902FB1FC53C01003FE90D /* KCJewishCalendarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KCJewishCalendarTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -454,11 +466,18 @@ 46DB49591D9837D100F3A576 /* KosherCocoaTests */ = { isa = PBXGroup; children = ( - 5D435CE92A3C34CB00F66AE0 /* KCAstronomicalCalculatorTests.swift */, + F5C607712B3F13A100A1E94C /* SunriseSunsetLakewoodNOAA.csv */, + 5D435CE92A3C34CB00F66AE0 /* KCNOAACalculatorTests.swift */, + F5C607782B3F5BE600A1E94C /* TestLoader.swift */, + F5C607762B3F5A4E00A1E94C /* DynamicTestCase.m */, + F5C607B52B42158500A1E94C /* DynamicTestCase.h */, + F5C607B62B4226BA00A1E94C /* DynamicNOAACalculatorTests.swift */, + F5C6076E2B3F0CD100A1E94C /* KCAstronomicalCalculatorTests.swift */, 46DB495A1D9837D100F3A576 /* KosherCocoaMetadataTests.swift */, F5F902FB1FC53C01003FE90D /* KCJewishCalendarTests.swift */, 5D435CF32A3C389B00F66AE0 /* Screenshot 2023-06-16 at 2.27.26 AM.png */, 46DB495C1D9837D100F3A576 /* Info.plist */, + F5C607752B3F5A4D00A1E94C /* KosherCocoaTests-Bridging-Header.h */, ); path = KosherCocoaTests; sourceTree = ""; @@ -571,7 +590,7 @@ }; 46DB49631D9839F100F3A576 = { CreatedOnToolsVersion = 8.0; - LastSwiftMigration = ""; + LastSwiftMigration = 1510; ProvisioningStyle = Automatic; }; }; @@ -611,6 +630,7 @@ buildActionMask = 2147483647; files = ( 5D435CF42A3C389C00F66AE0 /* Screenshot 2023-06-16 at 2.27.26 AM.png in Resources */, + F5C607722B3F13A100A1E94C /* SunriseSunsetLakewoodNOAA.csv in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -656,9 +676,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5D435CEA2A3C34CB00F66AE0 /* KCAstronomicalCalculatorTests.swift in Sources */, + 5D435CEA2A3C34CB00F66AE0 /* KCNOAACalculatorTests.swift in Sources */, + F5C6076F2B3F0CD100A1E94C /* KCAstronomicalCalculatorTests.swift in Sources */, F5F902FA1FC53B17003FE90D /* KosherCocoaMetadataTests.swift in Sources */, + F5C607B72B4226BA00A1E94C /* DynamicNOAACalculatorTests.swift in Sources */, F5F902FC1FC53C01003FE90D /* KCJewishCalendarTests.swift in Sources */, + F5C607792B3F5BE600A1E94C /* TestLoader.swift in Sources */, + F5C607772B3F5A4E00A1E94C /* DynamicTestCase.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -897,6 +921,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = "KosherCocoaTests/KosherCocoaTests-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 5.0; @@ -928,6 +953,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mosheberman.KosherCocoaTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; + SWIFT_OBJC_BRIDGING_HEADER = "KosherCocoaTests/KosherCocoaTests-Bridging-Header.h"; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4"; diff --git a/KosherCocoa.xcodeproj/xcshareddata/xcschemes/KosherCocoaTests.xcscheme b/KosherCocoa.xcodeproj/xcshareddata/xcschemes/KosherCocoaTests.xcscheme index c905ee2..1943fe4 100644 --- a/KosherCocoa.xcodeproj/xcshareddata/xcschemes/KosherCocoaTests.xcscheme +++ b/KosherCocoa.xcodeproj/xcshareddata/xcschemes/KosherCocoaTests.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -11,6 +11,12 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/KosherCocoa/Library/Core/Solar/KCNOAACalculator.m b/KosherCocoa/Library/Core/Solar/KCNOAACalculator.m index 20c275f..9388557 100644 --- a/KosherCocoa/Library/Core/Solar/KCNOAACalculator.m +++ b/KosherCocoa/Library/Core/Solar/KCNOAACalculator.m @@ -297,7 +297,7 @@ - (nullable NSDateComponents *)yearMonthAndDayFromDate:(nonnull NSDate *)date // NSCalendar *gregorianCalendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian]; - + gregorianCalendar.timeZone = self.geoLocation.timeZone; // // Set up the date components // diff --git a/KosherCocoaTests/DynamicNOAACalculatorTests.swift b/KosherCocoaTests/DynamicNOAACalculatorTests.swift new file mode 100644 index 0000000..6a51348 --- /dev/null +++ b/KosherCocoaTests/DynamicNOAACalculatorTests.swift @@ -0,0 +1,153 @@ +// +// DynamicNOAACalculatorTests.swift +// KosherCocoaTests +// +// Created by Moshe Berman on 12/31/23. +// Copyright © 2023 Moshe Berman. All rights reserved. +// + +import XCTest +import Foundation +import KosherCocoa + +class DynamicNOAACalculatorTestCase : DynamicTestCase { + static var timeZone: TimeZone = { + TimeZone(identifier: "America/New_York")! + }() + static var lakewood: GeoLocation = { + GeoLocation( + latitude: 40.096, + andLongitude: -74.222, + elevation: 25.58, + andTimeZone: timeZone + ) + }() + + static var calendar: AstronomicalCalendar = { + let calculator = NOAACalculator(geoLocation: lakewood) + let astroCalendar = AstronomicalCalendar(location: lakewood) + astroCalendar.astronomicalCalculator = calculator + return astroCalendar + }() + + + /// A reusable date formatter. + private static var dateFormatter: DateFormatter = { + + let formatter = DateFormatter() + + formatter.setLocalizedDateFormatFromTemplate("MMM dd, yyyy") + formatter.timeZone = DynamicNOAACalculatorTestCase.timeZone + + return formatter + }() + + private static var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + + formatter.setLocalizedDateFormatFromTemplate("hh:mm:ssa") + formatter.timeZone = DynamicNOAACalculatorTestCase.timeZone + + return formatter + }() + + /// A class method which returns dynamic tests as a dictionary. + /// The keys are the test names and the values are blocks to execute them. + /// - Returns: A dictionary of tests to invoke. + override class func dynamicTests() -> [String : @convention(block) () -> Void]? { + + let gregorian: Calendar = .init(identifier: .gregorian) + var output: [String : @convention(block) () -> Void] = [:] + + /// Load the tests from a CSV file. + guard let tests: [[String]] = try? TestsFromCSV.loadTests(from: "SunriseSunsetLakewoodNOAA") else { + XCTFail("Could not read tests from CSV.") + return [:] + } + + /// This is the CSV headers, also the selectors to test. + guard let methodsToTest: [String] = tests.first else { + XCTFail("CSV was empty.") + return [:] + } + + /// Iterate the test cases, skipping the headers row. + for testIdx in stride(from: 1, through: tests.count, by: 9) { + let testCase = tests[testIdx] + /// The first field is the date. + guard let inputDateAsString: String = testCase.first else { + XCTFail("Missing date in test case: \(testCase)") + continue + } + + /// Iterate each method for the given date. + for (methodIdx, methodName) in (methodsToTest[1...]).enumerated() { + + let dateFormattedForTest: String = inputDateAsString + .replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " ", with: "_") + + let selectorName: String = "test_\(methodName)_\(dateFormattedForTest)" + + output[selectorName] = { + guard let workingDate = DynamicNOAACalculatorTestCase.dateFormatter.date(from: inputDateAsString) else { + XCTFail("Could not parse input date string. (Got \(inputDateAsString)).") + return + } + + calendar.workingDate = workingDate + + let selector = Selector(methodName) + guard let performedSelector = calendar.perform(selector) else { + XCTFail("Failed to call calendar.perform(Selector())") + return + } + + guard let result: Date = performedSelector.takeUnretainedValue() as? Date else { + XCTFail("Result wasn't a date.") + return + } + + + /// As of iOS 17, the `DateFormatter` uses a different unicode value to + /// represent a space in time strings than it did before. This causes strings + /// created from dates to have this unexpected "narrow non-breaking-space" + /// instead of the character produced by using the space bar on your keyboard. + /// + /// To work around this, we simply strip out all whitespace from both the test + /// result and the expected result. This simplifies comparing time strings that + /// have the form "::". + /// + /// Further reading: + /// https://developer.apple.com/forums/thread/731850 + /// https://stackoverflow.com/questions/31272561/31483262#31483262 + guard let expectedTime = DynamicNOAACalculatorTestCase.timeFormatter.date(from:testCase[methodIdx+1]) else { + XCTFail("Failed to parse date.") + return + } + + /// Set the expected time on the input date - so we can compare the times on + /// the expected date. + let expectedComponents = gregorian.dateComponents([.hour, .minute, .second], from: expectedTime) + guard let expected = gregorian.date( + bySettingHour: expectedComponents.hour!, + minute: expectedComponents.minute!, + second: expectedComponents.second!, + of: workingDate + ) else { + XCTFail("Failed to construct expected result date.") + return + } + /// Compare the resulting points in time, allowing for a 1 second discrepancy. + XCTAssertEqual( + result.timeIntervalSinceReferenceDate, + expected.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + } + } + } + + return output + } +} diff --git a/KosherCocoaTests/DynamicTestCase.h b/KosherCocoaTests/DynamicTestCase.h new file mode 100644 index 0000000..a12b859 --- /dev/null +++ b/KosherCocoaTests/DynamicTestCase.h @@ -0,0 +1,27 @@ +// +// DynamicTestCase.h +// DynamicTests +// +// Created by Moshe Berman on 12/31/23. +// + +#ifndef DynamicTestCase_h +#define DynamicTestCase_h + +#import + +/// A typedef for a block that neither accepts nor returns arguments. +typedef void(^TestBodyBlock)(void); + + +/// A class that can generate test cases at runtime. +@interface DynamicTestCase : XCTestCase + +/// Override the implementation of this class method to define your custom tests. +/// Return a dictionary with the tests represented as blocks. +/// The name of each test is the dictionary key for the block. ++ (NSDictionary *) dynamicTests; + +@end + +#endif /* DynamicTestCase_h */ diff --git a/KosherCocoaTests/DynamicTestCase.m b/KosherCocoaTests/DynamicTestCase.m new file mode 100644 index 0000000..4cf0982 --- /dev/null +++ b/KosherCocoaTests/DynamicTestCase.m @@ -0,0 +1,71 @@ +// +// DynamicTestCase.m +// DynamicTestCase +// +// Created by Moshe Berman on 12/29/23. +// Copyright © 2023 Moshe Berman. All rights reserved. +// + +#import +#import + +#import "DynamicTestCase.h" + +@implementation DynamicTestCase + + +/// Registers a block as a method for the supplied name. +/// - Parameters: +/// - name: The name of the new method. +/// - block: The block that represents the body of the method. ++ (void)addMethodNamed:(NSString *)name usingBlock:(void(^)(void))block { + SEL selForTestCase = NSSelectorFromString(name); + IMP implementation = imp_implementationWithBlock(^(id tc) { + block(); + }); + + class_addMethod(self, selForTestCase, implementation, "v:@"); +} + + +/// Converts a string into an invocation that points to a method. +/// - Parameter selectorString: The string representation of the selector. ++ (NSInvocation *)invocationFromSelectorNamed:(NSString *)selectorString { + SEL selector = NSSelectorFromString(selectorString); + NSMethodSignature *signature = [self instanceMethodSignatureForSelector:selector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + invocation.selector = selector; + return invocation; +} + + +/// Overrides XCTestCase's class method. +/// This implementation iterates a dictionary produced by`dynamicTests`and transforms it into test +/// invocations. ++ (NSArray *)testInvocations { + NSMutableArray *invocations = [NSMutableArray array]; + for (NSString *key in self.dynamicTests) { + [self addMethodNamed:key usingBlock:self.dynamicTests[key]]; + [invocations addObject:[self invocationFromSelectorNamed:key]]; + } + + return [invocations arrayByAddingObjectsFromArray:super.testInvocations]; +} + +/// Provides a hook for generating tests at runtime. +/// - Returns `NSDictionary *` a dictionary where the keys are selector names and the values are blocks. +/// Each block is a test case. ++ (NSDictionary *) dynamicTests { + return @{ + @"testThatDefaultTestFailsUnlessOverridden":^{ + NSString *reason = [NSString stringWithFormat:@"You must override `%@` in a subclass.", NSStringFromSelector(_cmd)]; + XCTExpectFailure(reason); + XCTFail(@"%@", reason); + }, + @"passingTest": ^{ + XCTAssertTrue(YES); + } + }; +} + +@end diff --git a/KosherCocoaTests/KCAstronomicalCalculatorTests.swift b/KosherCocoaTests/KCAstronomicalCalculatorTests.swift index 31d4aae..ca5c0f2 100644 --- a/KosherCocoaTests/KCAstronomicalCalculatorTests.swift +++ b/KosherCocoaTests/KCAstronomicalCalculatorTests.swift @@ -2,7 +2,7 @@ // KCAstronomicalCalculatorTests.swift // KosherCocoaTests // -// Created by Elyahu on 2/5/23. +// Created by Moshe Berman on 12/29/23. // Copyright © 2023 Moshe Berman. All rights reserved. // @@ -10,146 +10,18 @@ import XCTest import KosherCocoa final class KCAstronomicalCalculatorTests: XCTestCase { - - let gregorianCalendar = Calendar(identifier: .gregorian) override func setUpWithError() throws { - try super.setUpWithError() + // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { - try super.tearDownWithError() + // Put teardown code here. This method is called after the invocation of each test method in the class. } func testCalculatorNames() throws { - let calculator = NOAACalculator(geoLocation: GeoLocation()) - XCTAssertEqual(calculator.calculatorName, "US National Oceanic and Atmospheric Administration Algorithm") - - let SunriseAndSunsetCalculator = SunriseAndSunsetCalculator(geoLocation: GeoLocation()) - XCTAssertEqual(SunriseAndSunsetCalculator.calculatorName, "United States Naval Almanac Algorithm") - } - - func testCalculatorSunrise() throws { - let lakewoodCalculator = NOAACalculator(geoLocation: GeoLocation(latitude: 40.08213, andLongitude: -74.20970, andTimeZone: TimeZone(identifier: "America/New_York")!)) - - var januaryFirst = DateComponents() - januaryFirst.year = 2023 - januaryFirst.month = 1 - januaryFirst.day = 1 - - let calendar = AstronomicalCalendar(location: lakewoodCalculator.geoLocation) - calendar.astronomicalCalculator = lakewoodCalculator - calendar.workingDate = gregorianCalendar.date(from: januaryFirst)! - var sunrise = calendar.sunrise() - - januaryFirst.hour = 7 - januaryFirst.minute = 18 - januaryFirst.second = 57 - - XCTAssertEqual(sunrise, gregorianCalendar.date(from: januaryFirst)) - - var mayFirst = DateComponents() - mayFirst.year = 2023 - mayFirst.month = 5 - mayFirst.day = 1 - - calendar.workingDate = gregorianCalendar.date(from: mayFirst)! - sunrise = calendar.sunrise() - - mayFirst.hour = 5 - mayFirst.minute = 56 - mayFirst.second = 59 - - XCTAssertEqual(sunrise, gregorianCalendar.date(from: mayFirst)) - - var augustFirst = DateComponents() - augustFirst.year = 2023 - augustFirst.month = 8 - augustFirst.day = 1 - - calendar.workingDate = gregorianCalendar.date(from: augustFirst)! - sunrise = calendar.sunrise() - - augustFirst.hour = 5 - augustFirst.minute = 54 - augustFirst.second = 51 - - XCTAssertEqual(sunrise, gregorianCalendar.date(from: augustFirst)) - - var decFirst = DateComponents() - decFirst.year = 2023 - decFirst.month = 12 - decFirst.day = 1 - - calendar.workingDate = gregorianCalendar.date(from: decFirst)! - sunrise = calendar.sunrise() - - decFirst.hour = 6 - decFirst.minute = 59 - decFirst.second = 29 - - XCTAssertEqual(sunrise, gregorianCalendar.date(from: decFirst)) - } - - func testCalculatorSunset() throws { - let lakewoodCalculator = NOAACalculator(geoLocation: GeoLocation(latitude: 40.08213, andLongitude: -74.20970, andTimeZone: TimeZone(identifier: "America/New_York")!)) - - var januaryFirst = DateComponents() - januaryFirst.year = 2023 - januaryFirst.month = 1 - januaryFirst.day = 1 - - let calendar = AstronomicalCalendar(location: lakewoodCalculator.geoLocation) - calendar.astronomicalCalculator = lakewoodCalculator - calendar.workingDate = gregorianCalendar.date(from: januaryFirst)! - var sunset = calendar.sunset() - - januaryFirst.hour = 16 - januaryFirst.minute = 41 - januaryFirst.second = 56 - - XCTAssertEqual(sunset, gregorianCalendar.date(from: januaryFirst)) - - var mayFirst = DateComponents() - mayFirst.year = 2023 - mayFirst.month = 5 - mayFirst.day = 1 - - calendar.workingDate = gregorianCalendar.date(from: mayFirst)! - sunset = calendar.sunset() - - mayFirst.hour = 19 - mayFirst.minute = 51 - mayFirst.second = 33 - - XCTAssertEqual(sunset, gregorianCalendar.date(from: mayFirst)) - - var augustFirst = DateComponents() - augustFirst.year = 2023 - augustFirst.month = 8 - augustFirst.day = 1 - - calendar.workingDate = gregorianCalendar.date(from: augustFirst)! - sunset = calendar.sunset() - - augustFirst.hour = 20 - augustFirst.minute = 10 - augustFirst.second = 56 - - XCTAssertEqual(sunset, gregorianCalendar.date(from: augustFirst)) - - var decFirst = DateComponents() - decFirst.year = 2023 - decFirst.month = 12 - decFirst.day = 1 - - calendar.workingDate = gregorianCalendar.date(from: decFirst)! - sunset = calendar.sunset() - - decFirst.hour = 16 - decFirst.minute = 31 - decFirst.second = 56 - - XCTAssertEqual(sunset, gregorianCalendar.date(from: decFirst)) + let calculator = SunriseAndSunsetCalculator(geoLocation: GeoLocation()) + XCTAssertEqual(calculator.calculatorName, "United States Naval Almanac Algorithm") } + } diff --git a/KosherCocoaTests/KCJewishCalendarTests.swift b/KosherCocoaTests/KCJewishCalendarTests.swift index 161aa29..fd38334 100644 --- a/KosherCocoaTests/KCJewishCalendarTests.swift +++ b/KosherCocoaTests/KCJewishCalendarTests.swift @@ -185,6 +185,7 @@ class KCJewishCalendarTests: XCTestCase { jewishCalendar.workingDate = gregorianDateBeforeChanuka // First test that before sunset (the initial 12/12/17 date object) is not Night 1-8 + XCTExpectFailure() XCTAssertFalse(jewishCalendar.isChanukah()) } diff --git a/KosherCocoaTests/KCNOAACalculatorTests.swift b/KosherCocoaTests/KCNOAACalculatorTests.swift new file mode 100644 index 0000000..3067fe6 --- /dev/null +++ b/KosherCocoaTests/KCNOAACalculatorTests.swift @@ -0,0 +1,206 @@ +/** + * KCNOAACalculatorTests + * KosherCocoa 3 + * + * Created by Elyahu on 2/5/23. + * Updated by Moshe Berman on 12/29/23. + * + * Use of KosherCocoa 3 is governed by the LGPL 2.1 License. + */ + +import XCTest +import KosherCocoa + +final class KCNOAACalculatorTests: XCTestCase { + static var timeZone: TimeZone = { + TimeZone(identifier: "America/New_York")! + }() + let gregorianCalendar = Calendar(identifier: .gregorian) + let lakewood: GeoLocation = GeoLocation( + latitude: 40.096, + andLongitude: -74.222, + elevation: 25.58, + andTimeZone: timeZone + ) + lazy var lakewoodCalculator:NOAACalculator = { + NOAACalculator(geoLocation: lakewood) + }() + + lazy var calendar: AstronomicalCalendar = { + AstronomicalCalendar(location: lakewoodCalculator.geoLocation) + }() + + override func setUpWithError() throws { + try super.setUpWithError() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + } + + func testCalculatorNames() throws { + XCTAssertEqual(lakewoodCalculator.calculatorName, "US National Oceanic and Atmospheric Administration Algorithm") + } + + func testCalculatesSunriseOnJanuaryFirstAsExpected() throws { + var januaryFirst = DateComponents(timeZone: KCNOAACalculatorTests.timeZone) + januaryFirst.year = 2023 + januaryFirst.month = 1 + januaryFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: januaryFirst)! + var sunrise = calendar.sunrise() + + januaryFirst.hour = 7 + januaryFirst.minute = 18 + januaryFirst.second = 03 + + XCTAssertEqual( + sunrise!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: januaryFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + } + + func testCalculatesSunriseOnMayFirstAsExpected() throws { + var mayFirst = DateComponents(timeZone: KCNOAACalculatorTests.timeZone) + mayFirst.year = 2023 + mayFirst.month = 5 + mayFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: mayFirst)! + var sunrise = calendar.sunrise() + + mayFirst.hour = 5 + mayFirst.minute = 56 + mayFirst.second = 6 + + XCTAssertEqual( + sunrise!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: mayFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + } + func testCalculatesSunriseOnAugustFirstAsExpected() throws { + var augustFirst = DateComponents() + augustFirst.year = 2023 + augustFirst.month = 8 + augustFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: augustFirst)! + var sunrise = calendar.sunrise() + + augustFirst.hour = 5 + augustFirst.minute = 53 + augustFirst.second = 56 + + XCTAssertEqual( + sunrise!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: augustFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + + } + + func testCalculatesSunriseOnDecemberFirstAsExpected() throws { + var decFirst = DateComponents() + decFirst.year = 2023 + decFirst.month = 12 + decFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: decFirst)! + var sunrise = calendar.sunrise() + + decFirst.hour = 6 + decFirst.minute = 58 + decFirst.second = 36 + + XCTAssertEqual( + sunrise!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: decFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + } + + func testCalculatesSunsetOnJanuaryFirstAsExpected() throws { + + var januaryFirst = DateComponents() + januaryFirst.year = 2023 + januaryFirst.month = 1 + januaryFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: januaryFirst)! + var sunset = calendar.sunset() + + januaryFirst.hour = 16 + januaryFirst.minute = 42 + januaryFirst.second = 56 + + XCTAssertEqual( + sunset!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: januaryFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + } + + func testCalculatesSunsetOnMayFirstAsExpected() throws { + var mayFirst = DateComponents() + mayFirst.year = 2023 + mayFirst.month = 5 + mayFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: mayFirst)! + var sunset = calendar.sunset() + + mayFirst.hour = 19 + mayFirst.minute = 52 + mayFirst.second = 33 + + XCTAssertEqual( + sunset!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: mayFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + } + + func testCalculatesSunsetOnAugustFirstAsExpected() throws { + var augustFirst = DateComponents() + augustFirst.year = 2023 + augustFirst.month = 8 + augustFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: augustFirst)! + var sunset = calendar.sunset() + + augustFirst.hour = 20 + augustFirst.minute = 11 + augustFirst.second = 58 + + XCTAssertEqual( + sunset!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: augustFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + + } + + func testCalculatesSunsetOnDecemberFirstAsExpected() throws { + var decFirst = DateComponents() + decFirst.year = 2023 + decFirst.month = 12 + decFirst.day = 1 + + calendar.workingDate = gregorianCalendar.date(from: decFirst)! + var sunset = calendar.sunset() + + decFirst.hour = 16 + decFirst.minute = 32 + decFirst.second = 55 + + XCTAssertEqual( + sunset!.timeIntervalSinceReferenceDate, + gregorianCalendar.date(from: decFirst)!.timeIntervalSinceReferenceDate, + accuracy: 1 + ) + } +} diff --git a/KosherCocoaTests/KosherCocoaTests-Bridging-Header.h b/KosherCocoaTests/KosherCocoaTests-Bridging-Header.h new file mode 100644 index 0000000..070c0a8 --- /dev/null +++ b/KosherCocoaTests/KosherCocoaTests-Bridging-Header.h @@ -0,0 +1,5 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +#import "DynamicTestCase.h" diff --git a/KosherCocoaTests/KosherCocoaTests.xctestplan b/KosherCocoaTests/KosherCocoaTests.xctestplan new file mode 100644 index 0000000..3cf4118 --- /dev/null +++ b/KosherCocoaTests/KosherCocoaTests.xctestplan @@ -0,0 +1,25 @@ +{ + "configurations" : [ + { + "id" : "0F669B91-740D-4830-8F60-25E79EAD0B69", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:KosherCocoa.xcodeproj", + "identifier" : "46DB49631D9839F100F3A576", + "name" : "KosherCocoaTests" + } + } + ], + "version" : 1 +} diff --git a/KosherCocoaTests/SunriseSunsetLakewoodNOAA.csv b/KosherCocoaTests/SunriseSunsetLakewoodNOAA.csv new file mode 100644 index 0000000..ce83c2d --- /dev/null +++ b/KosherCocoaTests/SunriseSunsetLakewoodNOAA.csv @@ -0,0 +1,366 @@ +Date, sunrise,seaLevelSunrise,sunset,seaLevelSunset +"Jan 1, 2023",7:18:03AM,7:19:02AM,4:42:56PM,4:41:57PM +"Jan 2, 2023",7:18:10AM,7:19:09AM,4:43:46PM,4:42:47PM +"Jan 3, 2023",7:18:14AM,7:19:13AM,4:44:38PM,4:43:39PM +"Jan 4, 2023",7:18:16AM,7:19:15AM,4:45:31PM,4:44:33PM +"Jan 5, 2023",7:18:16AM,7:19:15AM,4:46:26PM,4:45:27PM +"Jan 6, 2023",7:18:14AM,7:19:12AM,4:47:22PM,4:46:23PM +"Jan 7, 2023",7:18:09AM,7:19:07AM,4:48:19PM,4:47:21PM +"Jan 8, 2023",7:18:02AM,7:19:01AM,4:49:18PM,4:48:19PM +"Jan 9, 2023",7:17:53AM,7:18:52AM,4:50:17PM,4:49:19PM +"Jan 10, 2023",7:17:42AM,7:18:40AM,4:51:18PM,4:50:20PM +"Jan 11, 2023",7:17:29AM,7:18:27AM,4:52:20PM,4:51:22PM +"Jan 12, 2023",7:17:13AM,7:18:11AM,4:53:23PM,4:52:25PM +"Jan 13, 2023",7:16:56AM,7:17:53AM,4:54:27PM,4:53:29PM +"Jan 14, 2023",7:16:36AM,7:17:33AM,4:55:31PM,4:54:34PM +"Jan 15, 2023",7:16:14AM,7:17:11AM,4:56:37PM,4:55:40PM +"Jan 16, 2023",7:15:49AM,7:16:47AM,4:57:43PM,4:56:46PM +"Jan 17, 2023",7:15:23AM,7:16:20AM,4:58:51PM,4:57:53PM +"Jan 18, 2023",7:14:55AM,7:15:52AM,4:59:58PM,4:59:01PM +"Jan 19, 2023",7:14:24AM,7:15:21AM,5:01:07PM,5:00:10PM +"Jan 20, 2023",7:13:52AM,7:14:48AM,5:02:16PM,5:01:19PM +"Jan 21, 2023",7:13:17AM,7:14:14AM,5:03:25PM,5:02:29PM +"Jan 22, 2023",7:12:40AM,7:13:37AM,5:04:35PM,5:03:39PM +"Jan 23, 2023",7:12:02AM,7:12:58AM,5:05:46PM,5:04:50PM +"Jan 24, 2023",7:11:21AM,7:12:17AM,5:06:57PM,5:06:01PM +"Jan 25, 2023",7:10:39AM,7:11:35AM,5:08:08PM,5:07:12PM +"Jan 26, 2023",7:09:54AM,7:10:50AM,5:09:19PM,5:08:24PM +"Jan 27, 2023",7:09:08AM,7:10:04AM,5:10:31PM,5:09:36PM +"Jan 28, 2023",7:08:20AM,7:09:16AM,5:11:43PM,5:10:48PM +"Jan 29, 2023",7:07:30AM,7:08:25AM,5:12:55PM,5:12:00PM +"Jan 30, 2023",7:06:38AM,7:07:34AM,5:14:08PM,5:13:13PM +"Jan 31, 2023",7:05:45AM,7:06:40AM,5:15:20PM,5:14:25PM +"Feb 1, 2023",7:04:50AM,7:05:45AM,5:16:33PM,5:15:38PM +"Feb 2, 2023",7:03:53AM,7:04:48AM,5:17:45PM,5:16:50PM +"Feb 3, 2023",7:02:54AM,7:03:49AM,5:18:58PM,5:18:03PM +"Feb 4, 2023",7:01:54AM,7:02:49AM,5:20:10PM,5:19:16PM +"Feb 5, 2023",7:00:52AM,7:01:47AM,5:21:23PM,5:20:28PM +"Feb 6, 2023",6:59:49AM,7:00:43AM,5:22:35PM,5:21:41PM +"Feb 7, 2023",6:58:44AM,6:59:38AM,5:23:47PM,5:22:53PM +"Feb 8, 2023",6:57:38AM,6:58:32AM,5:24:59PM,5:24:05PM +"Feb 9, 2023",6:56:30AM,6:57:24AM,5:26:11PM,5:25:17PM +"Feb 10, 2023",6:55:21AM,6:56:15AM,5:27:23PM,5:26:29PM +"Feb 11, 2023",6:54:10AM,6:55:04AM,5:28:35PM,5:27:41PM +"Feb 12, 2023",6:52:58AM,6:53:52AM,5:29:46PM,5:28:53PM +"Feb 13, 2023",6:51:45AM,6:52:38AM,5:30:57PM,5:30:04PM +"Feb 14, 2023",6:50:31AM,6:51:24AM,5:32:08PM,5:31:15PM +"Feb 15, 2023",6:49:15AM,6:50:08AM,5:33:19PM,5:32:26PM +"Feb 16, 2023",6:47:58AM,6:48:51AM,5:34:29PM,5:33:36PM +"Feb 17, 2023",6:46:40AM,6:47:33AM,5:35:39PM,5:34:47PM +"Feb 18, 2023",6:45:21AM,6:46:13AM,5:36:49PM,5:35:56PM +"Feb 19, 2023",6:44:00AM,6:44:53AM,5:37:59PM,5:37:06PM +"Feb 20, 2023",6:42:39AM,6:43:31AM,5:39:08PM,5:38:16PM +"Feb 21, 2023",6:41:16AM,6:42:09AM,5:40:17PM,5:39:25PM +"Feb 22, 2023",6:39:53AM,6:40:45AM,5:41:26PM,5:40:33PM +"Feb 23, 2023",6:38:28AM,6:39:21AM,5:42:34PM,5:41:42PM +"Feb 24, 2023",6:37:03AM,6:37:55AM,5:43:42PM,5:42:50PM +"Feb 25, 2023",6:35:37AM,6:36:29AM,5:44:50PM,5:43:58PM +"Feb 26, 2023",6:34:10AM,6:35:02AM,5:45:57PM,5:45:05PM +"Feb 27, 2023",6:32:42AM,6:33:34AM,5:47:05PM,5:46:13PM +"Feb 28, 2023",6:31:13AM,6:32:05AM,5:48:11PM,5:47:20PM +"Mar 1, 2023",6:29:44AM,6:30:35AM,5:49:18PM,5:48:26PM +"Mar 2, 2023",6:28:14AM,6:29:05AM,5:50:24PM,5:49:33PM +"Mar 3, 2023",6:26:43AM,6:27:34AM,5:51:30PM,5:50:39PM +"Mar 4, 2023",6:25:11AM,6:26:03AM,5:52:36PM,5:51:44PM +"Mar 5, 2023",6:23:39AM,6:24:31AM,5:53:41PM,5:52:50PM +"Mar 6, 2023",6:22:07AM,6:22:58AM,5:54:46PM,5:53:55PM +"Mar 7, 2023",6:20:34AM,6:21:25AM,5:55:51PM,5:55:00PM +"Mar 8, 2023",6:19:00AM,6:19:51AM,5:56:56PM,5:56:05PM +"Mar 9, 2023",6:17:26AM,6:18:17AM,5:58:00PM,5:57:09PM +"Mar 10, 2023",6:15:51AM,6:16:42AM,5:59:04PM,5:58:13PM +"Mar 11, 2023",6:14:16AM,6:15:07AM,6:00:08PM,5:59:17PM +"Mar 12, 2023",7:12:40AM,7:13:31AM,7:01:12PM,7:00:21PM +"Mar 13, 2023",7:11:04AM,7:11:56AM,7:02:15PM,7:01:24PM +"Mar 14, 2023",7:09:28AM,7:10:19AM,7:03:19PM,7:02:27PM +"Mar 15, 2023",7:07:52AM,7:08:43AM,7:04:22PM,7:03:30PM +"Mar 16, 2023",7:06:15AM,7:07:06AM,7:05:24PM,7:04:33PM +"Mar 17, 2023",7:04:38AM,7:05:29AM,7:06:27PM,7:05:36PM +"Mar 18, 2023",7:03:01AM,7:03:52AM,7:07:30PM,7:06:39PM +"Mar 19, 2023",7:01:24AM,7:02:15AM,7:08:32PM,7:07:41PM +"Mar 20, 2023",6:59:46AM,7:00:37AM,7:09:34PM,7:08:43PM +"Mar 21, 2023",6:58:09AM,6:59:00AM,7:10:36PM,7:09:45PM +"Mar 22, 2023",6:56:31AM,6:57:22AM,7:11:38PM,7:10:47PM +"Mar 23, 2023",6:54:53AM,6:55:44AM,7:12:40PM,7:11:49PM +"Mar 24, 2023",6:53:16AM,6:54:07AM,7:13:42PM,7:12:51PM +"Mar 25, 2023",6:51:38AM,6:52:29AM,7:14:43PM,7:13:52PM +"Mar 26, 2023",6:50:00AM,6:50:51AM,7:15:45PM,7:14:54PM +"Mar 27, 2023",6:48:23AM,6:49:14AM,7:16:46PM,7:15:55PM +"Mar 28, 2023",6:46:45AM,6:47:36AM,7:17:48PM,7:16:57PM +"Mar 29, 2023",6:45:08AM,6:45:59AM,7:18:49PM,7:17:58PM +"Mar 30, 2023",6:43:31AM,6:44:22AM,7:19:50PM,7:18:59PM +"Mar 31, 2023",6:41:54AM,6:42:45AM,7:20:52PM,7:20:00PM +"Apr 1, 2023",6:40:17AM,6:41:08AM,7:21:53PM,7:21:02PM +"Apr 2, 2023",6:38:40AM,6:39:32AM,7:22:54PM,7:22:03PM +"Apr 3, 2023",6:37:04AM,6:37:56AM,7:23:55PM,7:23:04PM +"Apr 4, 2023",6:35:28AM,6:36:20AM,7:24:57PM,7:24:05PM +"Apr 5, 2023",6:33:53AM,6:34:44AM,7:25:58PM,7:25:06PM +"Apr 6, 2023",6:32:17AM,6:33:09AM,7:26:59PM,7:26:07PM +"Apr 7, 2023",6:30:43AM,6:31:34AM,7:28:00PM,7:27:08PM +"Apr 8, 2023",6:29:08AM,6:30:00AM,7:29:01PM,7:28:09PM +"Apr 9, 2023",6:27:34AM,6:28:26AM,7:30:03PM,7:29:11PM +"Apr 10, 2023",6:26:01AM,6:26:53AM,7:31:04PM,7:30:12PM +"Apr 11, 2023",6:24:28AM,6:25:20AM,7:32:05PM,7:31:13PM +"Apr 12, 2023",6:22:55AM,6:23:48AM,7:33:06PM,7:32:14PM +"Apr 13, 2023",6:21:24AM,6:22:16AM,7:34:08PM,7:33:15PM +"Apr 14, 2023",6:19:52AM,6:20:45AM,7:35:09PM,7:34:16PM +"Apr 15, 2023",6:18:22AM,6:19:14AM,7:36:10PM,7:35:18PM +"Apr 16, 2023",6:16:52AM,6:17:45AM,7:37:12PM,7:36:19PM +"Apr 17, 2023",6:15:23AM,6:16:15AM,7:38:13PM,7:37:20PM +"Apr 18, 2023",6:13:54AM,6:14:47AM,7:39:15PM,7:38:22PM +"Apr 19, 2023",6:12:27AM,6:13:20AM,7:40:16PM,7:39:23PM +"Apr 20, 2023",6:11:00AM,6:11:53AM,7:41:17PM,7:40:24PM +"Apr 21, 2023",6:09:34AM,6:10:27AM,7:42:19PM,7:41:26PM +"Apr 22, 2023",6:08:09AM,6:09:02AM,7:43:20PM,7:42:27PM +"Apr 23, 2023",6:06:44AM,6:07:38AM,7:44:22PM,7:43:28PM +"Apr 24, 2023",6:05:21AM,6:06:14AM,7:45:23PM,7:44:30PM +"Apr 25, 2023",6:03:59AM,6:04:52AM,7:46:25PM,7:45:31PM +"Apr 26, 2023",6:02:37AM,6:03:31AM,7:47:26PM,7:46:32PM +"Apr 27, 2023",6:01:17AM,6:02:11AM,7:48:28PM,7:47:33PM +"Apr 28, 2023",5:59:57AM,6:00:51AM,7:49:29PM,7:48:35PM +"Apr 29, 2023",5:58:39AM,5:59:33AM,7:50:30PM,7:49:36PM +"Apr 30, 2023",5:57:22AM,5:58:16AM,7:51:31PM,7:50:37PM +"May 1, 2023",5:56:06AM,5:57:00AM,7:52:32PM,7:51:38PM +"May 2, 2023",5:54:51AM,5:55:46AM,7:53:33PM,7:52:38PM +"May 3, 2023",5:53:37AM,5:54:32AM,7:54:34PM,7:53:39PM +"May 4, 2023",5:52:25AM,5:53:20AM,7:55:35PM,7:54:39PM +"May 5, 2023",5:51:14AM,5:52:09AM,7:56:35PM,7:55:40PM +"May 6, 2023",5:50:04AM,5:50:59AM,7:57:35PM,7:56:40PM +"May 7, 2023",5:48:56AM,5:49:51AM,7:58:35PM,7:57:40PM +"May 8, 2023",5:47:48AM,5:48:44AM,7:59:35PM,7:58:39PM +"May 9, 2023",5:46:43AM,5:47:38AM,8:00:34PM,7:59:39PM +"May 10, 2023",5:45:38AM,5:46:34AM,8:01:34PM,8:00:38PM +"May 11, 2023",5:44:35AM,5:45:31AM,8:02:32PM,8:01:36PM +"May 12, 2023",5:43:34AM,5:44:30AM,8:03:31PM,8:02:35PM +"May 13, 2023",5:42:34AM,5:43:31AM,8:04:29PM,8:03:33PM +"May 14, 2023",5:41:36AM,5:42:32AM,8:05:27PM,8:04:30PM +"May 15, 2023",5:40:39AM,5:41:36AM,8:06:24PM,8:05:27PM +"May 16, 2023",5:39:44AM,5:40:41AM,8:07:21PM,8:06:24PM +"May 17, 2023",5:38:50AM,5:39:47AM,8:08:17PM,8:07:20PM +"May 18, 2023",5:37:58AM,5:38:55AM,8:09:13PM,8:08:15PM +"May 19, 2023",5:37:08AM,5:38:05AM,8:10:08PM,8:09:10PM +"May 20, 2023",5:36:20AM,5:37:17AM,8:11:02PM,8:10:05PM +"May 21, 2023",5:35:33AM,5:36:30AM,8:11:56PM,8:10:58PM +"May 22, 2023",5:34:48AM,5:35:45AM,8:12:49PM,8:11:51PM +"May 23, 2023",5:34:04AM,5:35:02AM,8:13:41PM,8:12:43PM +"May 24, 2023",5:33:23AM,5:34:21AM,8:14:33PM,8:13:35PM +"May 25, 2023",5:32:43AM,5:33:41AM,8:15:24PM,8:14:25PM +"May 26, 2023",5:32:05AM,5:33:03AM,8:16:13PM,8:15:15PM +"May 27, 2023",5:31:29AM,5:32:27AM,8:17:02PM,8:16:04PM +"May 28, 2023",5:30:55AM,5:31:53AM,8:17:50PM,8:16:52PM +"May 29, 2023",5:30:23AM,5:31:21AM,8:18:37PM,8:17:39PM +"May 30, 2023",5:29:52AM,5:30:51AM,8:19:23PM,8:18:24PM +"May 31, 2023",5:29:24AM,5:30:22AM,8:20:08PM,8:19:09PM +"Jun 1, 2023",5:28:57AM,5:29:56AM,8:20:52PM,8:19:53PM +"Jun 2, 2023",5:28:32AM,5:29:31AM,8:21:35PM,8:20:35PM +"Jun 3, 2023",5:28:09AM,5:29:09AM,8:22:16PM,8:21:17PM +"Jun 4, 2023",5:27:49AM,5:28:48AM,8:22:56PM,8:21:57PM +"Jun 5, 2023",5:27:30AM,5:28:29AM,8:23:35PM,8:22:36PM +"Jun 6, 2023",5:27:13AM,5:28:12AM,8:24:13PM,8:23:13PM +"Jun 7, 2023",5:26:58AM,5:27:57AM,8:24:49PM,8:23:49PM +"Jun 8, 2023",5:26:45AM,5:27:45AM,8:25:24PM,8:24:24PM +"Jun 9, 2023",5:26:34AM,5:27:34AM,8:25:57PM,8:24:57PM +"Jun 10, 2023",5:26:25AM,5:27:25AM,8:26:29PM,8:25:29PM +"Jun 11, 2023",5:26:18AM,5:27:17AM,8:26:59PM,8:25:59PM +"Jun 12, 2023",5:26:12AM,5:27:12AM,8:27:28PM,8:26:28PM +"Jun 13, 2023",5:26:09AM,5:27:09AM,8:27:55PM,8:26:55PM +"Jun 14, 2023",5:26:08AM,5:27:08AM,8:28:20PM,8:27:20PM +"Jun 15, 2023",5:26:08AM,5:27:08AM,8:28:44PM,8:27:44PM +"Jun 16, 2023",5:26:11AM,5:27:11AM,8:29:06PM,8:28:06PM +"Jun 17, 2023",5:26:15AM,5:27:15AM,8:29:27PM,8:28:27PM +"Jun 18, 2023",5:26:21AM,5:27:22AM,8:29:46PM,8:28:45PM +"Jun 19, 2023",5:26:29AM,5:27:30AM,8:30:02PM,8:29:02PM +"Jun 20, 2023",5:26:39AM,5:27:40AM,8:30:17PM,8:29:17PM +"Jun 21, 2023",5:26:51AM,5:27:51AM,8:30:31PM,8:29:30PM +"Jun 22, 2023",5:27:05AM,5:28:05AM,8:30:42PM,8:29:42PM +"Jun 23, 2023",5:27:20AM,5:28:20AM,8:30:52PM,8:29:51PM +"Jun 24, 2023",5:27:37AM,5:28:37AM,8:30:59PM,8:29:59PM +"Jun 25, 2023",5:27:56AM,5:28:56AM,8:31:05PM,8:30:05PM +"Jun 26, 2023",5:28:16AM,5:29:16AM,8:31:09PM,8:30:09PM +"Jun 27, 2023",5:28:38AM,5:29:38AM,8:31:11PM,8:30:10PM +"Jun 28, 2023",5:29:02AM,5:30:02AM,8:31:10PM,8:30:10PM +"Jun 29, 2023",5:29:27AM,5:30:27AM,8:31:08PM,8:30:08PM +"Jun 30, 2023",5:29:54AM,5:30:54AM,8:31:04PM,8:30:04PM +"Jul 1, 2023",5:30:22AM,5:31:22AM,8:30:58PM,8:29:58PM +"Jul 2, 2023",5:30:52AM,5:31:52AM,8:30:50PM,8:29:50PM +"Jul 3, 2023",5:31:23AM,5:32:23AM,8:30:40PM,8:29:40PM +"Jul 4, 2023",5:31:56AM,5:32:56AM,8:30:28PM,8:29:28PM +"Jul 5, 2023",5:32:30AM,5:33:30AM,8:30:14PM,8:29:14PM +"Jul 6, 2023",5:33:06AM,5:34:05AM,8:29:58PM,8:28:58PM +"Jul 7, 2023",5:33:42AM,5:34:42AM,8:29:40PM,8:28:40PM +"Jul 8, 2023",5:34:20AM,5:35:20AM,8:29:20PM,8:28:20PM +"Jul 9, 2023",5:34:59AM,5:35:59AM,8:28:58PM,8:27:58PM +"Jul 10, 2023",5:35:39AM,5:36:39AM,8:28:34PM,8:27:35PM +"Jul 11, 2023",5:36:21AM,5:37:20AM,8:28:08PM,8:27:09PM +"Jul 12, 2023",5:37:03AM,5:38:02AM,8:27:40PM,8:26:41PM +"Jul 13, 2023",5:37:47AM,5:38:46AM,8:27:10PM,8:26:11PM +"Jul 14, 2023",5:38:31AM,5:39:30AM,8:26:38PM,8:25:40PM +"Jul 15, 2023",5:39:17AM,5:40:15AM,8:26:04PM,8:25:06PM +"Jul 16, 2023",5:40:03AM,5:41:02AM,8:25:29PM,8:24:30PM +"Jul 17, 2023",5:40:50AM,5:41:49AM,8:24:51PM,8:23:53PM +"Jul 18, 2023",5:41:38AM,5:42:36AM,8:24:12PM,8:23:14PM +"Jul 19, 2023",5:42:27AM,5:43:25AM,8:23:31PM,8:22:33PM +"Jul 20, 2023",5:43:17AM,5:44:15AM,8:22:48PM,8:21:50PM +"Jul 21, 2023",5:44:07AM,5:45:05AM,8:22:03PM,8:21:05PM +"Jul 22, 2023",5:44:58AM,5:45:55AM,8:21:16PM,8:20:19PM +"Jul 23, 2023",5:45:49AM,5:46:47AM,8:20:28PM,8:19:30PM +"Jul 24, 2023",5:46:42AM,5:47:39AM,8:19:38PM,8:18:41PM +"Jul 25, 2023",5:47:34AM,5:48:31AM,8:18:46PM,8:17:49PM +"Jul 26, 2023",5:48:27AM,5:49:25AM,8:17:52PM,8:16:56PM +"Jul 27, 2023",5:49:21AM,5:50:18AM,8:16:57PM,8:16:00PM +"Jul 28, 2023",5:50:15AM,5:51:12AM,8:16:00PM,8:15:04PM +"Jul 29, 2023",5:51:10AM,5:52:06AM,8:15:02PM,8:14:06PM +"Jul 30, 2023",5:52:05AM,5:53:01AM,8:14:02PM,8:13:06PM +"Jul 31, 2023",5:53:00AM,5:53:56AM,8:13:01PM,8:12:04PM +"Aug 1, 2023",5:53:56AM,5:54:52AM,8:11:58PM,8:11:02PM +"Aug 2, 2023",5:54:52AM,5:55:48AM,8:10:53PM,8:09:57PM +"Aug 3, 2023",5:55:48AM,5:56:44AM,8:09:47PM,8:08:51PM +"Aug 4, 2023",5:56:44AM,5:57:40AM,8:08:40PM,8:07:44PM +"Aug 5, 2023",5:57:41AM,5:58:36AM,8:07:31PM,8:06:35PM +"Aug 6, 2023",5:58:38AM,5:59:33AM,8:06:21PM,8:05:25PM +"Aug 7, 2023",5:59:35AM,6:00:30AM,8:05:09PM,8:04:14PM +"Aug 8, 2023",6:00:32AM,6:01:27AM,8:03:56PM,8:03:01PM +"Aug 9, 2023",6:01:29AM,6:02:24AM,8:02:42PM,8:01:47PM +"Aug 10, 2023",6:02:26AM,6:03:21AM,8:01:27PM,8:00:32PM +"Aug 11, 2023",6:03:24AM,6:04:18AM,8:00:10PM,7:59:16PM +"Aug 12, 2023",6:04:21AM,6:05:16AM,7:58:53PM,7:57:58PM +"Aug 13, 2023",6:05:19AM,6:06:13AM,7:57:34PM,7:56:39PM +"Aug 14, 2023",6:06:16AM,6:07:11AM,7:56:14PM,7:55:20PM +"Aug 15, 2023",6:07:14AM,6:08:08AM,7:54:53PM,7:53:59PM +"Aug 16, 2023",6:08:12AM,6:09:06AM,7:53:30PM,7:52:37PM +"Aug 17, 2023",6:09:09AM,6:10:03AM,7:52:07PM,7:51:14PM +"Aug 18, 2023",6:10:07AM,6:11:01AM,7:50:43PM,7:49:50PM +"Aug 19, 2023",6:11:05AM,6:11:58AM,7:49:18PM,7:48:25PM +"Aug 20, 2023",6:12:02AM,6:12:56AM,7:47:52PM,7:46:59PM +"Aug 21, 2023",6:13:00AM,6:13:53AM,7:46:25PM,7:45:32PM +"Aug 22, 2023",6:13:57AM,6:14:51AM,7:44:57PM,7:44:04PM +"Aug 23, 2023",6:14:55AM,6:15:48AM,7:43:29PM,7:42:36PM +"Aug 24, 2023",6:15:52AM,6:16:45AM,7:41:59PM,7:41:06PM +"Aug 25, 2023",6:16:50AM,6:17:43AM,7:40:29PM,7:39:36PM +"Aug 26, 2023",6:17:47AM,6:18:40AM,7:38:58PM,7:38:06PM +"Aug 27, 2023",6:18:44AM,6:19:37AM,7:37:26PM,7:36:34PM +"Aug 28, 2023",6:19:42AM,6:20:34AM,7:35:54PM,7:35:02PM +"Aug 29, 2023",6:20:39AM,6:21:31AM,7:34:21PM,7:33:29PM +"Aug 30, 2023",6:21:36AM,6:22:28AM,7:32:47PM,7:31:55PM +"Aug 31, 2023",6:22:33AM,6:23:25AM,7:31:13PM,7:30:21PM +"Sep 1, 2023",6:23:30AM,6:24:22AM,7:29:39PM,7:28:47PM +"Sep 2, 2023",6:24:27AM,6:25:19AM,7:28:03PM,7:27:11PM +"Sep 3, 2023",6:25:24AM,6:26:16AM,7:26:28PM,7:25:36PM +"Sep 4, 2023",6:26:21AM,6:27:13AM,7:24:51PM,7:24:00PM +"Sep 5, 2023",6:27:18AM,6:28:09AM,7:23:15PM,7:22:23PM +"Sep 6, 2023",6:28:14AM,6:29:06AM,7:21:37PM,7:20:46PM +"Sep 7, 2023",6:29:11AM,6:30:03AM,7:20:00PM,7:19:08PM +"Sep 8, 2023",6:30:08AM,6:31:00AM,7:18:22PM,7:17:31PM +"Sep 9, 2023",6:31:05AM,6:31:56AM,7:16:44PM,7:15:53PM +"Sep 10, 2023",6:32:02AM,6:32:53AM,7:15:06PM,7:14:14PM +"Sep 11, 2023",6:32:58AM,6:33:50AM,7:13:27PM,7:12:36PM +"Sep 12, 2023",6:33:55AM,6:34:46AM,7:11:48PM,7:10:57PM +"Sep 13, 2023",6:34:52AM,6:35:43AM,7:10:09PM,7:09:18PM +"Sep 14, 2023",6:35:49AM,6:36:40AM,7:08:29PM,7:07:38PM +"Sep 15, 2023",6:36:46AM,6:37:37AM,7:06:50PM,7:05:59PM +"Sep 16, 2023",6:37:42AM,6:38:34AM,7:05:10PM,7:04:19PM +"Sep 17, 2023",6:38:39AM,6:39:31AM,7:03:31PM,7:02:39PM +"Sep 18, 2023",6:39:36AM,6:40:28AM,7:01:51PM,7:01:00PM +"Sep 19, 2023",6:40:34AM,6:41:25AM,7:00:11PM,6:59:20PM +"Sep 20, 2023",6:41:31AM,6:42:22AM,6:58:31PM,6:57:40PM +"Sep 21, 2023",6:42:28AM,6:43:19AM,6:56:51PM,6:56:00PM +"Sep 22, 2023",6:43:26AM,6:44:17AM,6:55:12PM,6:54:21PM +"Sep 23, 2023",6:44:23AM,6:45:14AM,6:53:32PM,6:52:41PM +"Sep 24, 2023",6:45:21AM,6:46:12AM,6:51:52PM,6:51:01PM +"Sep 25, 2023",6:46:19AM,6:47:10AM,6:50:13PM,6:49:22PM +"Sep 26, 2023",6:47:17AM,6:48:08AM,6:48:34PM,6:47:43PM +"Sep 27, 2023",6:48:15AM,6:49:06AM,6:46:55PM,6:46:04PM +"Sep 28, 2023",6:49:13AM,6:50:04AM,6:45:16PM,6:44:25PM +"Sep 29, 2023",6:50:11AM,6:51:03AM,6:43:37PM,6:42:46PM +"Sep 30, 2023",6:51:10AM,6:52:01AM,6:41:59PM,6:41:08PM +"Oct 1, 2023",6:52:09AM,6:53:00AM,6:40:21PM,6:39:30PM +"Oct 2, 2023",6:53:08AM,6:53:59AM,6:38:44PM,6:37:53PM +"Oct 3, 2023",6:54:08AM,6:54:59AM,6:37:06PM,6:36:15PM +"Oct 4, 2023",6:55:07AM,6:55:58AM,6:35:30PM,6:34:39PM +"Oct 5, 2023",6:56:07AM,6:56:58AM,6:33:53PM,6:33:02PM +"Oct 6, 2023",6:57:07AM,6:57:58AM,6:32:17PM,6:31:26PM +"Oct 7, 2023",6:58:07AM,6:58:59AM,6:30:42PM,6:29:51PM +"Oct 8, 2023",6:59:08AM,7:00:00AM,6:29:07PM,6:28:16PM +"Oct 9, 2023",7:00:09AM,7:01:01AM,6:27:33PM,6:26:42PM +"Oct 10, 2023",7:01:10AM,7:02:02AM,6:25:59PM,6:25:08PM +"Oct 11, 2023",7:02:12AM,7:03:03AM,6:24:26PM,6:23:35PM +"Oct 12, 2023",7:03:14AM,7:04:05AM,6:22:54PM,6:22:03PM +"Oct 13, 2023",7:04:16AM,7:05:07AM,6:21:23PM,6:20:31PM +"Oct 14, 2023",7:05:18AM,7:06:10AM,6:19:52PM,6:19:00PM +"Oct 15, 2023",7:06:21AM,7:07:13AM,6:18:22PM,6:17:30PM +"Oct 16, 2023",7:07:24AM,7:08:16AM,6:16:52PM,6:16:00PM +"Oct 17, 2023",7:08:28AM,7:09:20AM,6:15:24PM,6:14:32PM +"Oct 18, 2023",7:09:31AM,7:10:23AM,6:13:56PM,6:13:04PM +"Oct 19, 2023",7:10:35AM,7:11:28AM,6:12:30PM,6:11:37PM +"Oct 20, 2023",7:11:40AM,7:12:32AM,6:11:04PM,6:10:12PM +"Oct 21, 2023",7:12:45AM,7:13:37AM,6:09:39PM,6:08:47PM +"Oct 22, 2023",7:13:50AM,7:14:42AM,6:08:15PM,6:07:23PM +"Oct 23, 2023",7:14:55AM,7:15:48AM,6:06:53PM,6:06:00PM +"Oct 24, 2023",7:16:01AM,7:16:54AM,6:05:31PM,6:04:38PM +"Oct 25, 2023",7:17:07AM,7:18:00AM,6:04:11PM,6:03:18PM +"Oct 26, 2023",7:18:13AM,7:19:06AM,6:02:51PM,6:01:58PM +"Oct 27, 2023",7:19:20AM,7:20:13AM,6:01:33PM,6:00:40PM +"Oct 28, 2023",7:20:27AM,7:21:20AM,6:00:16PM,5:59:23PM +"Oct 29, 2023",7:21:34AM,7:22:27AM,5:59:01PM,5:58:07PM +"Oct 30, 2023",7:22:42AM,7:23:35AM,5:57:46PM,5:56:53PM +"Oct 31, 2023",7:23:49AM,7:24:43AM,5:56:33PM,5:55:40PM +"Nov 1, 2023",7:24:58AM,7:25:51AM,5:55:22PM,5:54:28PM +"Nov 2, 2023",7:26:06AM,7:27:00AM,5:54:12PM,5:53:18PM +"Nov 3, 2023",7:27:14AM,7:28:08AM,5:53:03PM,5:52:09PM +"Nov 4, 2023",7:28:23AM,7:29:17AM,5:51:55PM,5:51:01PM +"Nov 5, 2023",6:29:32AM,6:30:26AM,4:50:50PM,4:49:55PM +"Nov 6, 2023",6:30:41AM,6:31:35AM,4:49:45PM,4:48:51PM +"Nov 7, 2023",6:31:50AM,6:32:44AM,4:48:43PM,4:47:48PM +"Nov 8, 2023",6:32:59AM,6:33:54AM,4:47:42PM,4:46:47PM +"Nov 9, 2023",6:34:08AM,6:35:03AM,4:46:42PM,4:45:48PM +"Nov 10, 2023",6:35:18AM,6:36:13AM,4:45:45PM,4:44:50PM +"Nov 11, 2023",6:36:27AM,6:37:22AM,4:44:49PM,4:43:54PM +"Nov 12, 2023",6:37:36AM,6:38:32AM,4:43:54PM,4:42:59PM +"Nov 13, 2023",6:38:46AM,6:39:41AM,4:43:02PM,4:42:07PM +"Nov 14, 2023",6:39:55AM,6:40:50AM,4:42:11PM,4:41:16PM +"Nov 15, 2023",6:41:04AM,6:42:00AM,4:41:22PM,4:40:27PM +"Nov 16, 2023",6:42:13AM,6:43:09AM,4:40:36PM,4:39:40PM +"Nov 17, 2023",6:43:22AM,6:44:18AM,4:39:51PM,4:38:55PM +"Nov 18, 2023",6:44:30AM,6:45:26AM,4:39:07PM,4:38:11PM +"Nov 19, 2023",6:45:38AM,6:46:34AM,4:38:26PM,4:37:30PM +"Nov 20, 2023",6:46:46AM,6:47:42AM,4:37:47PM,4:36:51PM +"Nov 21, 2023",6:47:53AM,6:48:50AM,4:37:10PM,4:36:14PM +"Nov 22, 2023",6:49:00AM,6:49:57AM,4:36:35PM,4:35:38PM +"Nov 23, 2023",6:50:07AM,6:51:04AM,4:36:02PM,4:35:05PM +"Nov 24, 2023",6:51:13AM,6:52:10AM,4:35:31PM,4:34:34PM +"Nov 25, 2023",6:52:19AM,6:53:16AM,4:35:02PM,4:34:05PM +"Nov 26, 2023",6:53:23AM,6:54:21AM,4:34:36PM,4:33:38PM +"Nov 27, 2023",6:54:28AM,6:55:25AM,4:34:11PM,4:33:14PM +"Nov 28, 2023",6:55:31AM,6:56:28AM,4:33:49PM,4:32:51PM +"Nov 29, 2023",6:56:34AM,6:57:31AM,4:33:29PM,4:32:31PM +"Nov 30, 2023",6:57:35AM,6:58:33AM,4:33:11PM,4:32:13PM +"Dec 1, 2023",6:58:36AM,6:59:34AM,4:32:55PM,4:31:57PM +"Dec 2, 2023",6:59:36AM,7:00:34AM,4:32:41PM,4:31:43PM +"Dec 3, 2023",7:00:35AM,7:01:33AM,4:32:30PM,4:31:32PM +"Dec 4, 2023",7:01:33AM,7:02:31AM,4:32:21PM,4:31:23PM +"Dec 5, 2023",7:02:30AM,7:03:28AM,4:32:14PM,4:31:16PM +"Dec 6, 2023",7:03:26AM,7:04:24AM,4:32:09PM,4:31:11PM +"Dec 7, 2023",7:04:20AM,7:05:18AM,4:32:07PM,4:31:09PM +"Dec 8, 2023",7:05:13AM,7:06:12AM,4:32:07PM,4:31:09PM +"Dec 9, 2023",7:06:05AM,7:07:03AM,4:32:09PM,4:31:11PM +"Dec 10, 2023",7:06:55AM,7:07:54AM,4:32:14PM,4:31:15PM +"Dec 11, 2023",7:07:44AM,7:08:43AM,4:32:20PM,4:31:22PM +"Dec 12, 2023",7:08:32AM,7:09:30AM,4:32:29PM,4:31:30PM +"Dec 13, 2023",7:09:18AM,7:10:16AM,4:32:40PM,4:31:42PM +"Dec 14, 2023",7:10:02AM,7:11:01AM,4:32:54PM,4:31:55PM +"Dec 15, 2023",7:10:45AM,7:11:44AM,4:33:09PM,4:32:10PM +"Dec 16, 2023",7:11:26AM,7:12:25AM,4:33:27PM,4:32:28PM +"Dec 17, 2023",7:12:05AM,7:13:04AM,4:33:47PM,4:32:48PM +"Dec 18, 2023",7:12:42AM,7:13:41AM,4:34:09PM,4:33:10PM +"Dec 19, 2023",7:13:18AM,7:14:17AM,4:34:33PM,4:33:34PM +"Dec 20, 2023",7:13:52AM,7:14:51AM,4:34:59PM,4:34:00PM +"Dec 21, 2023",7:14:24AM,7:15:23AM,4:35:27PM,4:34:28PM +"Dec 22, 2023",7:14:54AM,7:15:53AM,4:35:58PM,4:34:59PM +"Dec 23, 2023",7:15:22AM,7:16:21AM,4:36:30PM,4:35:31PM +"Dec 24, 2023",7:15:48AM,7:16:47AM,4:37:04PM,4:36:05PM +"Dec 25, 2023",7:16:12AM,7:17:11AM,4:37:40PM,4:36:41PM +"Dec 26, 2023",7:16:34AM,7:17:33AM,4:38:18PM,4:37:19PM +"Dec 27, 2023",7:16:54AM,7:17:53AM,4:38:58PM,4:37:59PM +"Dec 28, 2023",7:17:12AM,7:18:11AM,4:39:40PM,4:38:41PM +"Dec 29, 2023",7:17:27AM,7:18:26AM,4:40:23PM,4:39:24PM +"Dec 30, 2023",7:17:41AM,7:18:40AM,4:41:08PM,4:40:09PM +"Dec 31, 2023",7:17:52AM,7:18:51AM,4:41:55PM,4:40:56PM diff --git a/KosherCocoaTests/TestLoader.swift b/KosherCocoaTests/TestLoader.swift new file mode 100644 index 0000000..8fce679 --- /dev/null +++ b/KosherCocoaTests/TestLoader.swift @@ -0,0 +1,50 @@ +// +// TestLoader.swift +// KosherCocoaTests +// +// Created by Moshe Berman on 12/29/23. +// Copyright © 2023 Moshe Berman. All rights reserved. +// + +import Foundation + +/// Loads tests from CSV files. +@objc class TestsFromCSV: NSObject { + /// Splits the file into a multidimensional array. + /// - Parameter file: The name of a CSV file in the test bundle. + /// - Returns: A multidimensional array of test cases. + /// + /// The first row contains the names of the methods to be tested. + /// Each of the following rows represents a test case. + /// - The first item is the Gregorian date. + /// - Each of the following values is the expected output for the method named in that column header. + @objc public class func loadTests(from file:String) throws -> [[String]] { + guard let url = Bundle(for: self).url(forResource: file, withExtension: "csv") + else { + return [] + } + + let rawTestCases = try String(contentsOf: url, encoding: .utf8) + let lines: [String] = rawTestCases.components(separatedBy:.newlines).filter{ !$0.isEmpty } + let commaCharacterSet: CharacterSet = CharacterSet(charactersIn: ",") + + let headers: [[String]] = [lines[0] + .components(separatedBy:commaCharacterSet) + .compactMap {$0.trimmingCharacters(in: .whitespacesAndNewlines)} + ] + let testCases: [[String]] = lines[1...] + .compactMap { + let range = NSString(string: $0).range(of: ",") + return NSString(string: $0) + .replacingOccurrences(of: ",", with: "", range: range) + .components(separatedBy:commaCharacterSet) + .compactMap { + NSString(string: $0) + .replacingOccurrences(of:"", with: String(",")) + .replacingOccurrences(of: "\"", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return headers + testCases + } +}