From e0d9613e6cc20861d70f534c3760ca2544470e97 Mon Sep 17 00:00:00 2001 From: owdax Date: Wed, 22 Jan 2025 00:05:02 +0330 Subject: [PATCH 1/4] fix: Update TimeUtilities to use DateComponentsFormatter for consistent output and remove force unwrap --- Sources/SwiftDevKit/Time/TimeUtilities.swift | 179 ++++++++++++++++++ .../Time/TimeUtilitiesTests.swift | 117 ++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 Sources/SwiftDevKit/Time/TimeUtilities.swift create mode 100644 Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift diff --git a/Sources/SwiftDevKit/Time/TimeUtilities.swift b/Sources/SwiftDevKit/Time/TimeUtilities.swift new file mode 100644 index 0000000..d7d5063 --- /dev/null +++ b/Sources/SwiftDevKit/Time/TimeUtilities.swift @@ -0,0 +1,179 @@ +// TimeUtilities.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation + +/// A utility for handling time-related operations with a focus on human-readable formats +/// and common time calculations. +public enum TimeUtilities { + /// Style options for relative time formatting + public enum RelativeTimeStyle { + /// Short style (e.g., "2h ago", "in 3d") + case short + /// Medium style (e.g., "2 hours ago", "in 3 days") + case medium + /// Long style (e.g., "2 hours and 30 minutes ago", "in 3 days and 12 hours") + case long + /// Precise style with all components (e.g., "2 hours, 30 minutes, and 15 seconds ago") + case precise + } + + /// Units available for time calculations + public enum TimeUnit { + case seconds + case minutes + case hours + case days + case weeks + case months + case years + + var calendarComponent: Calendar.Component { + switch self { + case .seconds: .second + case .minutes: .minute + case .hours: .hour + case .days: .day + case .weeks: .weekOfMonth + case .months: .month + case .years: .year + } + } + } + + /// Formats a date relative to the current time. + /// + /// Example: + /// ```swift + /// let date = Date().addingTimeInterval(-7200) // 2 hours ago + /// let relative = TimeUtilities.relativeTime(from: date, style: .medium) + /// print(relative) // "2 hours ago" + /// ``` + /// + /// - Parameters: + /// - date: The date to format + /// - style: The style of the relative time string + /// - locale: The locale to use for formatting (default: current) + /// - Returns: A string representing the relative time + public static func relativeTime( + from date: Date, + style: RelativeTimeStyle, + locale: Locale = .current) -> String + { + let now = Date() + let calendar = Calendar.current + let components: Set = [.year, .month, .weekOfMonth, .day, .hour, .minute, .second] + let diffComponents = calendar.dateComponents(components, from: date, to: now) + + let formatter = DateComponentsFormatter() + formatter.calendar = calendar + formatter.unitsStyle = style == .short ? .abbreviated : .full + formatter.allowedUnits = [.year, .month, .weekOfMonth, .day, .hour, .minute, .second] + formatter.maximumUnitCount = 1 + + if date > now { + let interval = date.timeIntervalSince(now) + return "in " + (formatter.string(from: interval) ?? "") + } + + if let years = diffComponents.year, years > 0 { + return formatter.string(from: TimeInterval(years * 31_536_000)) ?? "" + } + if let months = diffComponents.month, months > 0 { + return formatter.string(from: TimeInterval(months * 2_592_000)) ?? "" + } + if let weeks = diffComponents.weekOfMonth, weeks > 0 { + return formatter.string(from: TimeInterval(weeks * 604_800)) ?? "" + } + if let days = diffComponents.day, days > 0 { + return formatter.string(from: TimeInterval(days * 86400)) ?? "" + } + if let hours = diffComponents.hour, hours > 0 { + return formatter.string(from: TimeInterval(hours * 3600)) ?? "" + } + if let minutes = diffComponents.minute, minutes > 0 { + return formatter.string(from: TimeInterval(minutes * 60)) ?? "" + } + + return "just now" + } + + /// Formats a time duration in a human-readable format. + /// + /// Example: + /// ```swift + /// let duration = TimeUtilities.formatDuration(seconds: 7384) // "2 hours, 3 minutes" + /// ``` + /// + /// - Parameters: + /// - seconds: The duration in seconds + /// - style: The formatting style to use + /// - locale: The locale to use for formatting + /// - Returns: A formatted string representing the duration + public static func formatDuration( + seconds: TimeInterval, + style: RelativeTimeStyle = .medium, + locale: Locale = .current) -> String + { + let formatter = DateComponentsFormatter() + formatter.calendar = Calendar.current + formatter.allowedUnits = [.year, .month, .weekOfMonth, .day, .hour, .minute, .second] + formatter.maximumUnitCount = style == .precise ? 6 : (style == .long ? 2 : 1) + formatter.unitsStyle = style == .short ? .abbreviated : .full + + return formatter.string(from: seconds) ?? "\(Int(seconds))s" + } + + /// Calculates the time remaining until a future date. + /// + /// Example: + /// ```swift + /// let future = Date().addingTimeInterval(7200) + /// let remaining = TimeUtilities.timeRemaining(until: future) // "2 hours" + /// ``` + /// + /// - Parameters: + /// - date: The future date + /// - style: The formatting style to use + /// - locale: The locale to use for formatting + /// - Returns: A string representing the time remaining, or nil if the date is in the past + public static func timeRemaining( + until date: Date, + style: RelativeTimeStyle = .medium, + locale: Locale = .current) -> String? + { + let now = Date() + guard date > now else { return nil } + + let seconds = date.timeIntervalSince(now) + return formatDuration(seconds: seconds, style: style, locale: locale) + } + + /// Checks if a date is within a specified time unit from now. + /// + /// Example: + /// ```swift + /// let date = Date().addingTimeInterval(-3600) // 1 hour ago + /// let isRecent = TimeUtilities.isWithin(date: date, unit: .hours, value: 2) // true + /// ``` + /// + /// - Parameters: + /// - date: The date to check + /// - unit: The time unit to use for comparison + /// - value: The number of units + /// - Returns: True if the date is within the specified time range + public static func isWithin(date: Date, unit: TimeUnit, value: Int) -> Bool { + let now = Date() + let calendar = Calendar.current + let components = calendar.dateComponents([unit.calendarComponent], from: date, to: now) + + guard let difference = components.value(for: unit.calendarComponent) else { + return false + } + + return abs(difference) <= value + } +} diff --git a/Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift b/Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift new file mode 100644 index 0000000..716c6d0 --- /dev/null +++ b/Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift @@ -0,0 +1,117 @@ +// TimeUtilitiesTests.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation +import Testing +@testable import SwiftDevKit + +struct TestError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} + +@Suite("Time Utilities Tests") +struct TimeUtilitiesTests { + @Test("Test relative time formatting") + func testRelativeTime() throws { + let now = Date() + + // Past times + let twoHoursAgo = now.addingTimeInterval(-7200) + let threeDaysAgo = now.addingTimeInterval(-259200) + guard let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) else { + throw TestError("Failed to create date one year ago") + } + + // Future times + let inOneHour = now.addingTimeInterval(3600) + let inTwoDays = now.addingTimeInterval(172800) + + // Test different styles + let shortStyle = TimeUtilities.relativeTime(from: twoHoursAgo, style: TimeUtilities.RelativeTimeStyle.short) + #expect(shortStyle.contains("2h")) + + let mediumStyle = TimeUtilities.relativeTime(from: threeDaysAgo, style: TimeUtilities.RelativeTimeStyle.medium) + #expect(mediumStyle.contains("3 days")) + + let longStyle = TimeUtilities.relativeTime(from: oneYearAgo, style: TimeUtilities.RelativeTimeStyle.long) + #expect(longStyle.contains("1 year")) + + // Test future dates + let futureShort = TimeUtilities.relativeTime(from: inOneHour, style: TimeUtilities.RelativeTimeStyle.short) + #expect(futureShort.contains("in 1h")) + + let futureMedium = TimeUtilities.relativeTime(from: inTwoDays, style: TimeUtilities.RelativeTimeStyle.medium) + #expect(futureMedium.contains("in 2 days")) + + // Test "just now" + let justNow = TimeUtilities.relativeTime(from: now, style: TimeUtilities.RelativeTimeStyle.medium) + #expect(justNow == "just now") + } + + @Test("Test duration formatting") + func testDurationFormatting() throws { + // Test different durations + let shortDuration = TimeUtilities.formatDuration(seconds: 45, style: TimeUtilities.RelativeTimeStyle.short) + #expect(shortDuration.contains("45s") || shortDuration.contains("45 sec")) + + let mediumDuration = TimeUtilities.formatDuration(seconds: 3665) // 1 hour, 1 minute, 5 seconds + #expect(mediumDuration.contains("hour")) + + let longDuration = TimeUtilities.formatDuration(seconds: 7384, style: TimeUtilities.RelativeTimeStyle.long) // 2 hours, 3 minutes + #expect(longDuration.contains("hours") && longDuration.contains("minutes")) + + let preciseDuration = TimeUtilities.formatDuration(seconds: 7384, style: TimeUtilities.RelativeTimeStyle.precise) + #expect(preciseDuration.contains("hours") && preciseDuration.contains("minutes") && preciseDuration.contains("seconds")) + } + + @Test("Test time remaining calculation") + func testTimeRemaining() throws { + let now = Date() + + // Test future date + let futureDate = now.addingTimeInterval(7200) // 2 hours in future + let remaining = TimeUtilities.timeRemaining(until: futureDate) + #expect(remaining?.contains("2 hours") == true) + + // Test past date + let pastDate = now.addingTimeInterval(-3600) // 1 hour in past + let pastRemaining = TimeUtilities.timeRemaining(until: pastDate) + #expect(pastRemaining == nil) + + // Test different styles + let shortRemaining = TimeUtilities.timeRemaining(until: futureDate, style: TimeUtilities.RelativeTimeStyle.short) + #expect(shortRemaining?.contains("2h") == true) + + let longRemaining = TimeUtilities.timeRemaining(until: futureDate, style: TimeUtilities.RelativeTimeStyle.long) + #expect(longRemaining?.contains("2 hours") == true) + } + + @Test("Test isWithin function") + func testIsWithin() throws { + let now = Date() + + // Test within range + let oneHourAgo = now.addingTimeInterval(-3600) + #expect(TimeUtilities.isWithin(date: oneHourAgo, unit: TimeUtilities.TimeUnit.hours, value: 2)) + + // Test outside range + let threeDaysAgo = now.addingTimeInterval(-259200) + #expect(!TimeUtilities.isWithin(date: threeDaysAgo, unit: TimeUtilities.TimeUnit.days, value: 2)) + + // Test edge cases + let exactlyTwoHoursAgo = now.addingTimeInterval(-7200) + #expect(TimeUtilities.isWithin(date: exactlyTwoHoursAgo, unit: TimeUtilities.TimeUnit.hours, value: 2)) + + // Test different units + let tenMinutesAgo = now.addingTimeInterval(-600) + #expect(TimeUtilities.isWithin(date: tenMinutesAgo, unit: TimeUtilities.TimeUnit.minutes, value: 15)) + #expect(!TimeUtilities.isWithin(date: tenMinutesAgo, unit: TimeUtilities.TimeUnit.minutes, value: 5)) + } +} \ No newline at end of file From 2e73e498e9f17abbf1db360b176086435bfde59a Mon Sep 17 00:00:00 2001 From: owdax Date: Wed, 22 Jan 2025 00:11:29 +0330 Subject: [PATCH 2/4] docs: Add comprehensive documentation for TimeUtilities with examples and use cases --- Sources/SwiftDevKit/Time/TimeUtilities.swift | 106 ++++++++++++++---- .../Time/TimeUtilitiesTests.swift | 69 +++++++----- 2 files changed, 120 insertions(+), 55 deletions(-) diff --git a/Sources/SwiftDevKit/Time/TimeUtilities.swift b/Sources/SwiftDevKit/Time/TimeUtilities.swift index d7d5063..05f0b2c 100644 --- a/Sources/SwiftDevKit/Time/TimeUtilities.swift +++ b/Sources/SwiftDevKit/Time/TimeUtilities.swift @@ -8,16 +8,22 @@ import Foundation /// A utility for handling time-related operations with a focus on human-readable formats /// and common time calculations. +/// +/// TimeUtilities provides a set of static functions for: +/// - Formatting relative times (e.g., "2 hours ago", "in 3 days") +/// - Formatting durations (e.g., "2 hours, 30 minutes") +/// - Calculating time remaining +/// - Checking if dates are within specific time ranges public enum TimeUtilities { /// Style options for relative time formatting public enum RelativeTimeStyle { - /// Short style (e.g., "2h ago", "in 3d") + /// Short style (e.g., "2h", "3d", "1y") case short - /// Medium style (e.g., "2 hours ago", "in 3 days") + /// Medium style (e.g., "2 hours", "3 days", "1 year") case medium - /// Long style (e.g., "2 hours and 30 minutes ago", "in 3 days and 12 hours") + /// Long style with multiple components (e.g., "2 hours and 30 minutes", "3 days and 12 hours") case long - /// Precise style with all components (e.g., "2 hours, 30 minutes, and 15 seconds ago") + /// Precise style with all components (e.g., "2 hours, 30 minutes, and 15 seconds") case precise } @@ -44,18 +50,30 @@ public enum TimeUtilities { } } - /// Formats a date relative to the current time. + /// Formats a date relative to the current time in a human-readable format. /// - /// Example: + /// This function is useful for displaying how long ago something happened or how far in the future it will occur. + /// The output format varies based on the style parameter and whether the date is in the past or future. + /// + /// Examples: /// ```swift - /// let date = Date().addingTimeInterval(-7200) // 2 hours ago - /// let relative = TimeUtilities.relativeTime(from: date, style: .medium) - /// print(relative) // "2 hours ago" + /// // Past dates + /// let twoHoursAgo = Date().addingTimeInterval(-7200) + /// TimeUtilities.relativeTime(from: twoHoursAgo, style: .short) // "2h" + /// TimeUtilities.relativeTime(from: twoHoursAgo, style: .medium) // "2 hours" + /// + /// // Future dates + /// let inOneHour = Date().addingTimeInterval(3600) + /// TimeUtilities.relativeTime(from: inOneHour, style: .short) // "in 1h" + /// TimeUtilities.relativeTime(from: inOneHour, style: .medium) // "in 1 hour" + /// + /// // Current time + /// TimeUtilities.relativeTime(from: Date(), style: .medium) // "just now" /// ``` /// /// - Parameters: /// - date: The date to format - /// - style: The style of the relative time string + /// - style: The style of the relative time string (default: .medium) /// - locale: The locale to use for formatting (default: current) /// - Returns: A string representing the relative time public static func relativeTime( @@ -103,15 +121,31 @@ public enum TimeUtilities { /// Formats a time duration in a human-readable format. /// - /// Example: + /// This function is useful for displaying durations like video lengths, time spent, or time remaining. + /// The output format varies based on the style parameter and the duration length. + /// + /// Examples: /// ```swift - /// let duration = TimeUtilities.formatDuration(seconds: 7384) // "2 hours, 3 minutes" + /// // Short durations + /// TimeUtilities.formatDuration(seconds: 45, style: .short) // "45s" + /// TimeUtilities.formatDuration(seconds: 45, style: .medium) // "45 seconds" + /// + /// // Medium durations + /// TimeUtilities.formatDuration(seconds: 3665, style: .medium) // "1 hour" + /// TimeUtilities.formatDuration(seconds: 3665, style: .long) // "1 hour, 1 minute" + /// + /// // Long durations with different styles + /// let duration = 7384 // 2 hours, 3 minutes, 4 seconds + /// TimeUtilities.formatDuration(seconds: duration, style: .short) // "2h" + /// TimeUtilities.formatDuration(seconds: duration, style: .medium) // "2 hours" + /// TimeUtilities.formatDuration(seconds: duration, style: .long) // "2 hours, 3 minutes" + /// TimeUtilities.formatDuration(seconds: duration, style: .precise) // "2 hours, 3 minutes, 4 seconds" /// ``` /// /// - Parameters: /// - seconds: The duration in seconds - /// - style: The formatting style to use - /// - locale: The locale to use for formatting + /// - style: The formatting style to use (default: .medium) + /// - locale: The locale to use for formatting (default: current) /// - Returns: A formatted string representing the duration public static func formatDuration( seconds: TimeInterval, @@ -127,18 +161,28 @@ public enum TimeUtilities { return formatter.string(from: seconds) ?? "\(Int(seconds))s" } - /// Calculates the time remaining until a future date. + /// Calculates and formats the time remaining until a future date. /// - /// Example: + /// This function is useful for countdown displays or showing time remaining until an event. + /// Returns nil if the provided date is in the past. + /// + /// Examples: /// ```swift - /// let future = Date().addingTimeInterval(7200) - /// let remaining = TimeUtilities.timeRemaining(until: future) // "2 hours" + /// // Future dates + /// let twoHoursLater = Date().addingTimeInterval(7200) + /// TimeUtilities.timeRemaining(until: twoHoursLater, style: .short) // "2h" + /// TimeUtilities.timeRemaining(until: twoHoursLater, style: .medium) // "2 hours" + /// TimeUtilities.timeRemaining(until: twoHoursLater, style: .long) // "2 hours" + /// + /// // Past dates + /// let oneHourAgo = Date().addingTimeInterval(-3600) + /// TimeUtilities.timeRemaining(until: oneHourAgo) // nil /// ``` /// /// - Parameters: /// - date: The future date - /// - style: The formatting style to use - /// - locale: The locale to use for formatting + /// - style: The formatting style to use (default: .medium) + /// - locale: The locale to use for formatting (default: current) /// - Returns: A string representing the time remaining, or nil if the date is in the past public static func timeRemaining( until date: Date, @@ -154,16 +198,30 @@ public enum TimeUtilities { /// Checks if a date is within a specified time unit from now. /// - /// Example: + /// This function is useful for determining if something happened recently or is coming up soon. + /// For example, checking if a message was sent within the last hour or if an event is within the next week. + /// + /// Examples: /// ```swift - /// let date = Date().addingTimeInterval(-3600) // 1 hour ago - /// let isRecent = TimeUtilities.isWithin(date: date, unit: .hours, value: 2) // true + /// // Check recent events + /// let oneHourAgo = Date().addingTimeInterval(-3600) + /// TimeUtilities.isWithin(date: oneHourAgo, unit: .hours, value: 2) // true + /// TimeUtilities.isWithin(date: oneHourAgo, unit: .minutes, value: 30) // false + /// + /// // Check upcoming events + /// let threeDaysLater = Date().addingTimeInterval(259200) + /// TimeUtilities.isWithin(date: threeDaysLater, unit: .days, value: 2) // false + /// TimeUtilities.isWithin(date: threeDaysLater, unit: .weeks, value: 1) // true + /// + /// // Edge cases + /// let exactlyTwoHours = Date().addingTimeInterval(-7200) + /// TimeUtilities.isWithin(date: exactlyTwoHours, unit: .hours, value: 2) // true /// ``` /// /// - Parameters: /// - date: The date to check /// - unit: The time unit to use for comparison - /// - value: The number of units + /// - value: The number of units to check within /// - Returns: True if the date is within the specified time range public static func isWithin(date: Date, unit: TimeUnit, value: Int) -> Bool { let now = Date() diff --git a/Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift b/Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift index 716c6d0..a7a1c30 100644 --- a/Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift +++ b/Tests/SwiftDevKitTests/Time/TimeUtilitiesTests.swift @@ -10,7 +10,7 @@ import Testing struct TestError: Error { let message: String - + init(_ message: String) { self.message = message } @@ -21,97 +21,104 @@ struct TimeUtilitiesTests { @Test("Test relative time formatting") func testRelativeTime() throws { let now = Date() - + // Past times let twoHoursAgo = now.addingTimeInterval(-7200) - let threeDaysAgo = now.addingTimeInterval(-259200) + let threeDaysAgo = now.addingTimeInterval(-259_200) guard let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) else { throw TestError("Failed to create date one year ago") } - + // Future times let inOneHour = now.addingTimeInterval(3600) - let inTwoDays = now.addingTimeInterval(172800) - + let inTwoDays = now.addingTimeInterval(172_800) + // Test different styles let shortStyle = TimeUtilities.relativeTime(from: twoHoursAgo, style: TimeUtilities.RelativeTimeStyle.short) #expect(shortStyle.contains("2h")) - + let mediumStyle = TimeUtilities.relativeTime(from: threeDaysAgo, style: TimeUtilities.RelativeTimeStyle.medium) #expect(mediumStyle.contains("3 days")) - + let longStyle = TimeUtilities.relativeTime(from: oneYearAgo, style: TimeUtilities.RelativeTimeStyle.long) #expect(longStyle.contains("1 year")) - + // Test future dates let futureShort = TimeUtilities.relativeTime(from: inOneHour, style: TimeUtilities.RelativeTimeStyle.short) #expect(futureShort.contains("in 1h")) - + let futureMedium = TimeUtilities.relativeTime(from: inTwoDays, style: TimeUtilities.RelativeTimeStyle.medium) #expect(futureMedium.contains("in 2 days")) - + // Test "just now" let justNow = TimeUtilities.relativeTime(from: now, style: TimeUtilities.RelativeTimeStyle.medium) #expect(justNow == "just now") } - + @Test("Test duration formatting") func testDurationFormatting() throws { // Test different durations let shortDuration = TimeUtilities.formatDuration(seconds: 45, style: TimeUtilities.RelativeTimeStyle.short) #expect(shortDuration.contains("45s") || shortDuration.contains("45 sec")) - + let mediumDuration = TimeUtilities.formatDuration(seconds: 3665) // 1 hour, 1 minute, 5 seconds #expect(mediumDuration.contains("hour")) - - let longDuration = TimeUtilities.formatDuration(seconds: 7384, style: TimeUtilities.RelativeTimeStyle.long) // 2 hours, 3 minutes + + let longDuration = TimeUtilities + .formatDuration(seconds: 7384, style: TimeUtilities.RelativeTimeStyle.long) // 2 hours, 3 minutes #expect(longDuration.contains("hours") && longDuration.contains("minutes")) - - let preciseDuration = TimeUtilities.formatDuration(seconds: 7384, style: TimeUtilities.RelativeTimeStyle.precise) - #expect(preciseDuration.contains("hours") && preciseDuration.contains("minutes") && preciseDuration.contains("seconds")) + + let preciseDuration = TimeUtilities.formatDuration( + seconds: 7384, + style: TimeUtilities.RelativeTimeStyle.precise) + #expect( + preciseDuration.contains("hours") && preciseDuration.contains("minutes") && preciseDuration + .contains("seconds")) } - + @Test("Test time remaining calculation") func testTimeRemaining() throws { let now = Date() - + // Test future date let futureDate = now.addingTimeInterval(7200) // 2 hours in future let remaining = TimeUtilities.timeRemaining(until: futureDate) #expect(remaining?.contains("2 hours") == true) - + // Test past date let pastDate = now.addingTimeInterval(-3600) // 1 hour in past let pastRemaining = TimeUtilities.timeRemaining(until: pastDate) #expect(pastRemaining == nil) - + // Test different styles - let shortRemaining = TimeUtilities.timeRemaining(until: futureDate, style: TimeUtilities.RelativeTimeStyle.short) + let shortRemaining = TimeUtilities.timeRemaining( + until: futureDate, + style: TimeUtilities.RelativeTimeStyle.short) #expect(shortRemaining?.contains("2h") == true) - + let longRemaining = TimeUtilities.timeRemaining(until: futureDate, style: TimeUtilities.RelativeTimeStyle.long) #expect(longRemaining?.contains("2 hours") == true) } - + @Test("Test isWithin function") func testIsWithin() throws { let now = Date() - + // Test within range let oneHourAgo = now.addingTimeInterval(-3600) #expect(TimeUtilities.isWithin(date: oneHourAgo, unit: TimeUtilities.TimeUnit.hours, value: 2)) - + // Test outside range - let threeDaysAgo = now.addingTimeInterval(-259200) + let threeDaysAgo = now.addingTimeInterval(-259_200) #expect(!TimeUtilities.isWithin(date: threeDaysAgo, unit: TimeUtilities.TimeUnit.days, value: 2)) - + // Test edge cases let exactlyTwoHoursAgo = now.addingTimeInterval(-7200) #expect(TimeUtilities.isWithin(date: exactlyTwoHoursAgo, unit: TimeUtilities.TimeUnit.hours, value: 2)) - + // Test different units let tenMinutesAgo = now.addingTimeInterval(-600) #expect(TimeUtilities.isWithin(date: tenMinutesAgo, unit: TimeUtilities.TimeUnit.minutes, value: 15)) #expect(!TimeUtilities.isWithin(date: tenMinutesAgo, unit: TimeUtilities.TimeUnit.minutes, value: 5)) } -} \ No newline at end of file +} From 0c0558bb1a85ca4c6ec55fc4528a5f82196cc148 Mon Sep 17 00:00:00 2001 From: owdax Date: Wed, 22 Jan 2025 00:50:36 +0330 Subject: [PATCH 3/4] fix: Update TimeZoneUtilities tests to handle DST transitions correctly --- .../SwiftDevKit/Time/TimeZoneUtilities.swift | 276 ++++++++++++++++++ .../Time/TimeZoneUtilitiesTests.swift | 226 ++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 Sources/SwiftDevKit/Time/TimeZoneUtilities.swift create mode 100644 Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift diff --git a/Sources/SwiftDevKit/Time/TimeZoneUtilities.swift b/Sources/SwiftDevKit/Time/TimeZoneUtilities.swift new file mode 100644 index 0000000..1cbee31 --- /dev/null +++ b/Sources/SwiftDevKit/Time/TimeZoneUtilities.swift @@ -0,0 +1,276 @@ +// TimeZoneUtilities.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation + +/// A utility for handling time zone conversions and formatting dates across different time zones. +public enum TimeZoneUtilities { + /// Style options for date and time formatting + public enum FormatStyle { + /// Date only (e.g., "Jan 21, 2025") + case dateOnly + /// Time only (e.g., "14:30") + case timeOnly + /// Date and time (e.g., "Jan 21, 2025 14:30") + case full + /// Short style (e.g., "1/21/25 14:30") + case short + /// Long style (e.g., "January 21, 2025 at 2:30 PM") + case long + } + + /// Converts a date from one time zone to another, handling DST transitions. + /// + /// This function is useful when you need to display or process dates in different time zones. + /// The returned date represents the same instant in time, just expressed in a different time zone. + /// It properly handles Daylight Saving Time (DST) transitions. + /// + /// Examples: + /// ```swift + /// let nyTime = Date() // Current time in New York + /// let nyZone = TimeZone(identifier: "America/New_York")! + /// let tokyoZone = TimeZone(identifier: "Asia/Tokyo")! + /// + /// // Convert NY time to Tokyo time + /// let tokyoTime = TimeZoneUtilities.convert(date: date, from: nyZone, to: tokyoZone) + /// ``` + /// + /// - Parameters: + /// - date: The date to convert + /// - fromZone: The source time zone + /// - toZone: The target time zone + /// - Returns: The converted date + public static func convert(date: Date, from fromZone: TimeZone, to toZone: TimeZone) -> Date { + let sourceSeconds = fromZone.secondsFromGMT(for: date) + let destinationSeconds = toZone.secondsFromGMT(for: date) + let interval = TimeInterval(destinationSeconds - sourceSeconds) + + return date.addingTimeInterval(interval) + } + + /// Formats a date for a specific time zone with the given style. + /// + /// This function is useful when you need to display dates in a specific time zone's format. + /// The output format varies based on the style parameter and respects the provided locale. + /// It automatically handles DST transitions and adjusts the output accordingly. + /// + /// Examples: + /// ```swift + /// let now = Date() + /// let tokyoZone = TimeZone(identifier: "Asia/Tokyo")! + /// + /// // Different format styles + /// TimeZoneUtilities.format(date: now, timeZone: tokyoZone, style: .dateOnly) // "Jan 21, 2025" + /// TimeZoneUtilities.format(date: now, timeZone: tokyoZone, style: .timeOnly) // "14:30" + /// TimeZoneUtilities.format(date: now, timeZone: tokyoZone, style: .full) // "Jan 21, 2025 14:30" + /// TimeZoneUtilities.format(date: now, timeZone: tokyoZone, style: .short) // "1/21/25 14:30" + /// TimeZoneUtilities.format(date: now, timeZone: tokyoZone, style: .long) // "January 21, 2025 at 2:30 PM" + /// + /// // With different locale + /// let japaneseLocale = Locale(identifier: "ja_JP") + /// TimeZoneUtilities.format(date: now, timeZone: tokyoZone, style: .full, locale: japaneseLocale) + /// ``` + /// + /// - Parameters: + /// - date: The date to format + /// - timeZone: The time zone to use for formatting + /// - style: The formatting style to use + /// - locale: The locale to use for formatting (default: current) + /// - Returns: A formatted string representing the date in the specified time zone + public static func format( + date: Date, + timeZone: TimeZone, + style: FormatStyle, + locale: Locale = .current) -> String + { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.locale = locale + + switch style { + case .dateOnly: + formatter.dateStyle = .medium + formatter.timeStyle = .none + case .timeOnly: + formatter.dateStyle = .none + formatter.timeStyle = .short + case .full: + formatter.dateStyle = .medium + formatter.timeStyle = .short + case .short: + formatter.dateStyle = .short + formatter.timeStyle = .short + case .long: + formatter.dateStyle = .long + formatter.timeStyle = .long + } + + return formatter.string(from: date) + } + + /// Returns a list of all available time zone identifiers grouped by region. + /// + /// This function provides access to all time zones supported by the system, + /// organized by geographical region for easier navigation. + /// + /// Example: + /// ```swift + /// let zones = TimeZoneUtilities.allTimeZones() + /// for (region, identifiers) in zones { + /// print("Region: \(region)") + /// for identifier in identifiers { + /// print(" - \(identifier)") + /// } + /// } + /// ``` + /// + /// - Returns: A dictionary with regions as keys and arrays of time zone identifiers as values + public static func allTimeZones() -> [String: [String]] { + var regions: [String: [String]] = [:] + + for identifier in TimeZone.knownTimeZoneIdentifiers.sorted() { + let components = identifier.split(separator: "/") + if components.count >= 2 { + let region = String(components[0]) + regions[region, default: []].append(identifier) + } else { + regions["Other", default: []].append(identifier) + } + } + + return regions + } + + /// Returns commonly used time zone identifiers grouped by region. + /// + /// This function provides a curated list of frequently used time zones, + /// organized by geographical region for easier selection. + /// + /// Example: + /// ```swift + /// let zones = TimeZoneUtilities.commonTimeZones() + /// for (region, identifiers) in zones { + /// print("Region: \(region)") + /// for identifier in identifiers { + /// print(" - \(identifier)") + /// } + /// } + /// ``` + /// + /// - Returns: A dictionary with regions as keys and arrays of time zone identifiers as values + public static func commonTimeZones() -> [String: [String]] { + let common = [ + "America": [ + "America/New_York", + "America/Los_Angeles", + "America/Chicago", + "America/Toronto", + "America/Vancouver", + "America/Mexico_City", + "America/Sao_Paulo", + "America/Argentina/Buenos_Aires", + ], + "Europe": [ + "Europe/London", + "Europe/Paris", + "Europe/Berlin", + "Europe/Rome", + "Europe/Madrid", + "Europe/Amsterdam", + "Europe/Moscow", + "Europe/Istanbul", + ], + "Asia": [ + "Asia/Tokyo", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Dubai", + "Asia/Hong_Kong", + "Asia/Seoul", + "Asia/Kolkata", + "Asia/Bangkok", + ], + "Pacific": [ + "Australia/Sydney", + "Pacific/Auckland", + "Australia/Melbourne", + "Pacific/Honolulu", + "Pacific/Fiji", + "Pacific/Guam", + ], + "Africa": [ + "Africa/Cairo", + "Africa/Lagos", + "Africa/Johannesburg", + "Africa/Nairobi", + "Africa/Casablanca", + ], + ] + + return common + } + + /// Returns the GMT offset for a given time zone at a specific date. + /// + /// This function returns a string representation of the time zone's offset from GMT, + /// taking into account Daylight Saving Time if applicable for the given date. + /// + /// Examples: + /// ```swift + /// let ny = TimeZone(identifier: "America/New_York")! + /// let date = Date() + /// + /// // Get current offset + /// TimeZoneUtilities.currentOffset(for: ny, at: date) // "-05:00" or "-04:00" depending on DST + /// ``` + /// + /// - Parameters: + /// - timeZone: The time zone to get the offset for + /// - date: The date at which to check the offset (default: current date) + /// - Returns: A string representation of the GMT offset (e.g., "+09:00", "-05:00") + public static func currentOffset(for timeZone: TimeZone, at date: Date = Date()) -> String { + let seconds = timeZone.secondsFromGMT(for: date) + let hours = abs(seconds) / 3600 + let minutes = (abs(seconds) % 3600) / 60 + let sign = seconds >= 0 ? "+" : "-" + return String(format: "%@%02d:%02d", sign, hours, minutes) + } + + /// Checks if a time zone is currently observing Daylight Saving Time. + /// + /// This function determines whether a given time zone is currently in DST. + /// + /// Example: + /// ```swift + /// let nyZone = TimeZone(identifier: "America/New_York")! + /// let isDST = TimeZoneUtilities.isDaylightSavingTime(in: nyZone) // true during DST + /// ``` + /// + /// - Parameter timeZone: The time zone to check + /// - Returns: true if the time zone is currently observing DST, false otherwise + public static func isDaylightSavingTime(in timeZone: TimeZone) -> Bool { + timeZone.isDaylightSavingTime(for: Date()) + } + + /// Gets the next DST transition date for a given time zone. + /// + /// This function finds the next date when the time zone will transition + /// either into or out of Daylight Saving Time. + /// + /// Example: + /// ```swift + /// let nyZone = TimeZone(identifier: "America/New_York")! + /// if let nextTransition = TimeZoneUtilities.nextDSTTransition(in: nyZone) { + /// print("Next DST transition: \(nextTransition)") + /// } + /// ``` + /// + /// - Parameter timeZone: The time zone to check + /// - Returns: The next DST transition date, or nil if the time zone doesn't observe DST + public static func nextDSTTransition(in timeZone: TimeZone) -> Date? { + timeZone.nextDaylightSavingTimeTransition + } +} diff --git a/Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift b/Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift new file mode 100644 index 0000000..dda57a7 --- /dev/null +++ b/Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift @@ -0,0 +1,226 @@ +// TimeZoneUtilitiesTests.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation +import Testing +@testable import SwiftDevKit + +@Suite("Time Zone Utilities Tests") +struct TimeZoneUtilitiesTests { + @Test("Test time zone conversion") + func testTimeZoneConversion() throws { + let date = Date(timeIntervalSince1970: 1705881600) // Jan 22, 2024 00:00:00 UTC + guard let utc = TimeZone(identifier: "UTC"), + let ny = TimeZone(identifier: "America/New_York"), + let tokyo = TimeZone(identifier: "Asia/Tokyo") else { + throw TestError("Failed to create time zones") + } + + // Test UTC to NY conversion + let nyTime = TimeZoneUtilities.convert(date: date, from: utc, to: ny) + let nyOffset = ny.secondsFromGMT(for: date) + #expect(nyTime.timeIntervalSince1970 == date.timeIntervalSince1970 + Double(nyOffset - utc.secondsFromGMT(for: date))) + + // Test UTC to Tokyo conversion + let tokyoTime = TimeZoneUtilities.convert(date: date, from: utc, to: tokyo) + let tokyoOffset = tokyo.secondsFromGMT(for: date) + #expect(tokyoTime.timeIntervalSince1970 == date.timeIntervalSince1970 + Double(tokyoOffset - utc.secondsFromGMT(for: date))) + + // Test conversion during DST transition + let dstDate = Date(timeIntervalSince1970: 1710061200) // March 10, 2024 07:00:00 UTC (DST start in US) + let beforeDST = TimeZoneUtilities.convert(date: dstDate, from: utc, to: ny) + let afterDST = TimeZoneUtilities.convert(date: dstDate.addingTimeInterval(7200), from: utc, to: ny) + #expect(afterDST.timeIntervalSince1970 - beforeDST.timeIntervalSince1970 == 7200) + } + + @Test("Test date formatting with time zones") + func testDateFormatting() throws { + let date = Date(timeIntervalSince1970: 1705881600) // Jan 22, 2024 00:00:00 UTC + guard let tokyo = TimeZone(identifier: "Asia/Tokyo"), + let ny = TimeZone(identifier: "America/New_York") else { + throw TestError("Failed to create time zones") + } + let enUS = Locale(identifier: "en_US") + + // Test different format styles + let dateOnly = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .dateOnly, locale: enUS) + #expect(dateOnly.contains("Jan")) + #expect(dateOnly.contains("2024")) + #expect(!dateOnly.contains(":")) // Should not contain time + + let timeOnly = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .timeOnly, locale: enUS) + #expect(timeOnly.contains(":")) + #expect(!timeOnly.contains("2024")) // Should not contain date + + let full = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .full, locale: enUS) + #expect(full.contains("Jan")) + #expect(full.contains("2024")) + #expect(full.contains(":")) + + // Test with Japanese locale + let jaJP = Locale(identifier: "ja_JP") + let jpFormat = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .full, locale: jaJP) + #expect(jpFormat.contains("2024")) + + // Test formatting during DST transition + let dstDate = Date(timeIntervalSince1970: 1710061200) // March 10, 2024 07:00:00 UTC (DST start in US) + let beforeDST = TimeZoneUtilities.format(date: dstDate, timeZone: ny, style: .full, locale: enUS) + let afterDST = TimeZoneUtilities.format(date: dstDate.addingTimeInterval(7200), timeZone: ny, style: .full, locale: enUS) + #expect(beforeDST != afterDST) + } + + @Test("Test GMT offset formatting") + func testOffsetFormatting() throws { + guard let tokyo = TimeZone(identifier: "Asia/Tokyo"), + let ny = TimeZone(identifier: "America/New_York"), + let utc = TimeZone(identifier: "UTC") else { + throw TestError("Failed to create time zones") + } + + // Test positive offset + let tokyoOffset = TimeZoneUtilities.currentOffset(for: tokyo) + #expect(tokyoOffset.hasPrefix("+")) + #expect(tokyoOffset.contains(":")) + + // Test negative offset + let nyOffset = TimeZoneUtilities.currentOffset(for: ny) + #expect(nyOffset.contains(":")) + + // Test zero offset + let utcOffset = TimeZoneUtilities.currentOffset(for: utc) + #expect(utcOffset == "+00:00") + + // Test offset during DST + // March 10, 2024 01:59:00 EST (before DST) + let beforeDSTDate = Date(timeIntervalSince1970: 1710050340) + // March 10, 2024 03:01:00 EDT (after DST) + let afterDSTDate = Date(timeIntervalSince1970: 1710054060) + + let beforeDST = TimeZoneUtilities.currentOffset(for: ny, at: beforeDSTDate) + let afterDST = TimeZoneUtilities.currentOffset(for: ny, at: afterDSTDate) + #expect(beforeDST == "-05:00") // EST offset + #expect(afterDST == "-04:00") // EDT offset + } + + @Test("Test DST detection") + func testDSTDetection() throws { + guard let ny = TimeZone(identifier: "America/New_York"), + let tokyo = TimeZone(identifier: "Asia/Tokyo"), + let utc = TimeZone(identifier: "UTC") else { + throw TestError("Failed to create time zones") + } + + // Test DST observation + let isDSTNY = TimeZoneUtilities.isDaylightSavingTime(in: ny) + #expect(isDSTNY == ny.isDaylightSavingTime(for: Date())) + + // Test non-DST zones + let isDSTTokyo = TimeZoneUtilities.isDaylightSavingTime(in: tokyo) + #expect(!isDSTTokyo) // Tokyo doesn't observe DST + + let isDSTUTC = TimeZoneUtilities.isDaylightSavingTime(in: utc) + #expect(!isDSTUTC) // UTC never observes DST + } + + @Test("Test next DST transition") + func testNextDSTTransition() throws { + guard let ny = TimeZone(identifier: "America/New_York"), + let tokyo = TimeZone(identifier: "Asia/Tokyo"), + let utc = TimeZone(identifier: "UTC") else { + throw TestError("Failed to create time zones") + } + + // Test zones with DST + let nyTransition = TimeZoneUtilities.nextDSTTransition(in: ny) + #expect(nyTransition != nil) + + // Test zones without DST + let tokyoTransition = TimeZoneUtilities.nextDSTTransition(in: tokyo) + #expect(tokyoTransition == nil) + + let utcTransition = TimeZoneUtilities.nextDSTTransition(in: utc) + #expect(utcTransition == nil) + } + + @Test("Test all time zones") + func testAllTimeZones() throws { + let zones = TimeZoneUtilities.allTimeZones() + + // Test structure + #expect(!zones.isEmpty) + #expect(zones.keys.contains("America")) + #expect(zones.keys.contains("Europe")) + #expect(zones.keys.contains("Asia")) + #expect(zones.keys.contains("Pacific")) + #expect(zones.keys.contains("Africa")) + + // Test completeness + let allSystemZones = Set(TimeZone.knownTimeZoneIdentifiers) + var allReturnedZones = Set() + for (_, identifiers) in zones { + allReturnedZones.formUnion(identifiers) + } + #expect(allReturnedZones == allSystemZones) + + // Test validity of identifiers + for (_, identifiers) in zones { + for identifier in identifiers { + #expect(TimeZone(identifier: identifier) != nil) + } + } + } + + @Test("Test common time zones") + func testCommonTimeZones() throws { + let zones = TimeZoneUtilities.commonTimeZones() + + // Test structure + #expect(zones.keys.contains("America")) + #expect(zones.keys.contains("Europe")) + #expect(zones.keys.contains("Asia")) + #expect(zones.keys.contains("Pacific")) + #expect(zones.keys.contains("Africa")) + + // Test content + #expect(zones["America"]?.contains("America/New_York") == true) + #expect(zones["Europe"]?.contains("Europe/London") == true) + #expect(zones["Asia"]?.contains("Asia/Tokyo") == true) + #expect(zones["Pacific"]?.contains("Australia/Sydney") == true) + #expect(zones["Africa"]?.contains("Africa/Cairo") == true) + + // Test validity of identifiers + for (_, identifiers) in zones { + for identifier in identifiers { + let timeZone = TimeZone(identifier: identifier) + #expect(timeZone != nil, "Invalid timezone identifier: \(identifier)") + } + } + + // Test that common zones are a subset of all zones + let allZones = TimeZoneUtilities.allTimeZones() + for (region, identifiers) in zones { + let commonSet = Set(identifiers) + var allSet = Set() + + // Handle special cases for regions + switch region { + case "Pacific": + allSet.formUnion(allZones["Pacific"] ?? []) + allSet.formUnion(allZones["Australia"] ?? []) + case "Asia": + allSet.formUnion(allZones["Asia"] ?? []) + // Handle special case for Kolkata (formerly Calcutta) + if allSet.contains("Asia/Calcutta") { + allSet.insert("Asia/Kolkata") + } + default: + allSet.formUnion(allZones[region] ?? []) + } + + #expect(allSet.isSuperset(of: commonSet), "Common zones for \(region) are not a subset of all zones") + } + } +} From 5fc7e5bd0b1b3318499cc70959b643103fab7903 Mon Sep 17 00:00:00 2001 From: owdax Date: Wed, 22 Jan 2025 01:00:05 +0330 Subject: [PATCH 4/4] style: Format code --- .../Time/TimeZoneUtilitiesTests.swift | 131 ++++++++++-------- 1 file changed, 72 insertions(+), 59 deletions(-) diff --git a/Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift b/Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift index dda57a7..fdeda9b 100644 --- a/Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift +++ b/Tests/SwiftDevKitTests/Time/TimeZoneUtilitiesTests.swift @@ -12,143 +12,156 @@ import Testing struct TimeZoneUtilitiesTests { @Test("Test time zone conversion") func testTimeZoneConversion() throws { - let date = Date(timeIntervalSince1970: 1705881600) // Jan 22, 2024 00:00:00 UTC + let date = Date(timeIntervalSince1970: 1_705_881_600) // Jan 22, 2024 00:00:00 UTC guard let utc = TimeZone(identifier: "UTC"), let ny = TimeZone(identifier: "America/New_York"), - let tokyo = TimeZone(identifier: "Asia/Tokyo") else { + let tokyo = TimeZone(identifier: "Asia/Tokyo") + else { throw TestError("Failed to create time zones") } - + // Test UTC to NY conversion let nyTime = TimeZoneUtilities.convert(date: date, from: utc, to: ny) let nyOffset = ny.secondsFromGMT(for: date) - #expect(nyTime.timeIntervalSince1970 == date.timeIntervalSince1970 + Double(nyOffset - utc.secondsFromGMT(for: date))) - + #expect( + nyTime.timeIntervalSince1970 == date + .timeIntervalSince1970 + Double(nyOffset - utc.secondsFromGMT(for: date))) + // Test UTC to Tokyo conversion let tokyoTime = TimeZoneUtilities.convert(date: date, from: utc, to: tokyo) let tokyoOffset = tokyo.secondsFromGMT(for: date) - #expect(tokyoTime.timeIntervalSince1970 == date.timeIntervalSince1970 + Double(tokyoOffset - utc.secondsFromGMT(for: date))) - + #expect( + tokyoTime.timeIntervalSince1970 == date + .timeIntervalSince1970 + Double(tokyoOffset - utc.secondsFromGMT(for: date))) + // Test conversion during DST transition - let dstDate = Date(timeIntervalSince1970: 1710061200) // March 10, 2024 07:00:00 UTC (DST start in US) + let dstDate = Date(timeIntervalSince1970: 1_710_061_200) // March 10, 2024 07:00:00 UTC (DST start in US) let beforeDST = TimeZoneUtilities.convert(date: dstDate, from: utc, to: ny) let afterDST = TimeZoneUtilities.convert(date: dstDate.addingTimeInterval(7200), from: utc, to: ny) #expect(afterDST.timeIntervalSince1970 - beforeDST.timeIntervalSince1970 == 7200) } - + @Test("Test date formatting with time zones") func testDateFormatting() throws { - let date = Date(timeIntervalSince1970: 1705881600) // Jan 22, 2024 00:00:00 UTC + let date = Date(timeIntervalSince1970: 1_705_881_600) // Jan 22, 2024 00:00:00 UTC guard let tokyo = TimeZone(identifier: "Asia/Tokyo"), - let ny = TimeZone(identifier: "America/New_York") else { + let ny = TimeZone(identifier: "America/New_York") + else { throw TestError("Failed to create time zones") } let enUS = Locale(identifier: "en_US") - + // Test different format styles let dateOnly = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .dateOnly, locale: enUS) #expect(dateOnly.contains("Jan")) #expect(dateOnly.contains("2024")) #expect(!dateOnly.contains(":")) // Should not contain time - + let timeOnly = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .timeOnly, locale: enUS) #expect(timeOnly.contains(":")) #expect(!timeOnly.contains("2024")) // Should not contain date - + let full = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .full, locale: enUS) #expect(full.contains("Jan")) #expect(full.contains("2024")) #expect(full.contains(":")) - + // Test with Japanese locale let jaJP = Locale(identifier: "ja_JP") let jpFormat = TimeZoneUtilities.format(date: date, timeZone: tokyo, style: .full, locale: jaJP) #expect(jpFormat.contains("2024")) - + // Test formatting during DST transition - let dstDate = Date(timeIntervalSince1970: 1710061200) // March 10, 2024 07:00:00 UTC (DST start in US) + let dstDate = Date(timeIntervalSince1970: 1_710_061_200) // March 10, 2024 07:00:00 UTC (DST start in US) let beforeDST = TimeZoneUtilities.format(date: dstDate, timeZone: ny, style: .full, locale: enUS) - let afterDST = TimeZoneUtilities.format(date: dstDate.addingTimeInterval(7200), timeZone: ny, style: .full, locale: enUS) + let afterDST = TimeZoneUtilities.format( + date: dstDate.addingTimeInterval(7200), + timeZone: ny, + style: .full, + locale: enUS) #expect(beforeDST != afterDST) } - + @Test("Test GMT offset formatting") func testOffsetFormatting() throws { guard let tokyo = TimeZone(identifier: "Asia/Tokyo"), let ny = TimeZone(identifier: "America/New_York"), - let utc = TimeZone(identifier: "UTC") else { + let utc = TimeZone(identifier: "UTC") + else { throw TestError("Failed to create time zones") } - + // Test positive offset let tokyoOffset = TimeZoneUtilities.currentOffset(for: tokyo) #expect(tokyoOffset.hasPrefix("+")) #expect(tokyoOffset.contains(":")) - + // Test negative offset let nyOffset = TimeZoneUtilities.currentOffset(for: ny) #expect(nyOffset.contains(":")) - + // Test zero offset let utcOffset = TimeZoneUtilities.currentOffset(for: utc) #expect(utcOffset == "+00:00") - + // Test offset during DST // March 10, 2024 01:59:00 EST (before DST) - let beforeDSTDate = Date(timeIntervalSince1970: 1710050340) + let beforeDSTDate = Date(timeIntervalSince1970: 1_710_050_340) // March 10, 2024 03:01:00 EDT (after DST) - let afterDSTDate = Date(timeIntervalSince1970: 1710054060) - + let afterDSTDate = Date(timeIntervalSince1970: 1_710_054_060) + let beforeDST = TimeZoneUtilities.currentOffset(for: ny, at: beforeDSTDate) let afterDST = TimeZoneUtilities.currentOffset(for: ny, at: afterDSTDate) #expect(beforeDST == "-05:00") // EST offset #expect(afterDST == "-04:00") // EDT offset } - + @Test("Test DST detection") func testDSTDetection() throws { guard let ny = TimeZone(identifier: "America/New_York"), let tokyo = TimeZone(identifier: "Asia/Tokyo"), - let utc = TimeZone(identifier: "UTC") else { + let utc = TimeZone(identifier: "UTC") + else { throw TestError("Failed to create time zones") } - + // Test DST observation let isDSTNY = TimeZoneUtilities.isDaylightSavingTime(in: ny) #expect(isDSTNY == ny.isDaylightSavingTime(for: Date())) - + // Test non-DST zones let isDSTTokyo = TimeZoneUtilities.isDaylightSavingTime(in: tokyo) #expect(!isDSTTokyo) // Tokyo doesn't observe DST - + let isDSTUTC = TimeZoneUtilities.isDaylightSavingTime(in: utc) #expect(!isDSTUTC) // UTC never observes DST } - + @Test("Test next DST transition") func testNextDSTTransition() throws { guard let ny = TimeZone(identifier: "America/New_York"), let tokyo = TimeZone(identifier: "Asia/Tokyo"), - let utc = TimeZone(identifier: "UTC") else { + let utc = TimeZone(identifier: "UTC") + else { throw TestError("Failed to create time zones") } - + // Test zones with DST let nyTransition = TimeZoneUtilities.nextDSTTransition(in: ny) #expect(nyTransition != nil) - + // Test zones without DST let tokyoTransition = TimeZoneUtilities.nextDSTTransition(in: tokyo) #expect(tokyoTransition == nil) - + let utcTransition = TimeZoneUtilities.nextDSTTransition(in: utc) #expect(utcTransition == nil) } - + @Test("Test all time zones") func testAllTimeZones() throws { let zones = TimeZoneUtilities.allTimeZones() - + // Test structure #expect(!zones.isEmpty) #expect(zones.keys.contains("America")) @@ -156,7 +169,7 @@ struct TimeZoneUtilitiesTests { #expect(zones.keys.contains("Asia")) #expect(zones.keys.contains("Pacific")) #expect(zones.keys.contains("Africa")) - + // Test completeness let allSystemZones = Set(TimeZone.knownTimeZoneIdentifiers) var allReturnedZones = Set() @@ -164,7 +177,7 @@ struct TimeZoneUtilitiesTests { allReturnedZones.formUnion(identifiers) } #expect(allReturnedZones == allSystemZones) - + // Test validity of identifiers for (_, identifiers) in zones { for identifier in identifiers { @@ -172,25 +185,25 @@ struct TimeZoneUtilitiesTests { } } } - + @Test("Test common time zones") func testCommonTimeZones() throws { let zones = TimeZoneUtilities.commonTimeZones() - + // Test structure #expect(zones.keys.contains("America")) #expect(zones.keys.contains("Europe")) #expect(zones.keys.contains("Asia")) #expect(zones.keys.contains("Pacific")) #expect(zones.keys.contains("Africa")) - + // Test content #expect(zones["America"]?.contains("America/New_York") == true) #expect(zones["Europe"]?.contains("Europe/London") == true) #expect(zones["Asia"]?.contains("Asia/Tokyo") == true) #expect(zones["Pacific"]?.contains("Australia/Sydney") == true) #expect(zones["Africa"]?.contains("Africa/Cairo") == true) - + // Test validity of identifiers for (_, identifiers) in zones { for identifier in identifiers { @@ -198,28 +211,28 @@ struct TimeZoneUtilitiesTests { #expect(timeZone != nil, "Invalid timezone identifier: \(identifier)") } } - + // Test that common zones are a subset of all zones let allZones = TimeZoneUtilities.allTimeZones() for (region, identifiers) in zones { let commonSet = Set(identifiers) var allSet = Set() - + // Handle special cases for regions switch region { - case "Pacific": - allSet.formUnion(allZones["Pacific"] ?? []) - allSet.formUnion(allZones["Australia"] ?? []) - case "Asia": - allSet.formUnion(allZones["Asia"] ?? []) - // Handle special case for Kolkata (formerly Calcutta) - if allSet.contains("Asia/Calcutta") { - allSet.insert("Asia/Kolkata") - } - default: - allSet.formUnion(allZones[region] ?? []) + case "Pacific": + allSet.formUnion(allZones["Pacific"] ?? []) + allSet.formUnion(allZones["Australia"] ?? []) + case "Asia": + allSet.formUnion(allZones["Asia"] ?? []) + // Handle special case for Kolkata (formerly Calcutta) + if allSet.contains("Asia/Calcutta") { + allSet.insert("Asia/Kolkata") + } + default: + allSet.formUnion(allZones[region] ?? []) } - + #expect(allSet.isSuperset(of: commonSet), "Common zones for \(region) are not a subset of all zones") } }