Skip to content

Commit 8ef2536

Browse files
RISCfutureclaude
andcommitted
Normalize Cycle interface for consistency across libraries
- Rename `current` to `effective` and `isCurrent` to `isEffective` - Make `yymm` internal and remove redundant `fullYear` - Make `previous`/`next` return optional for API consistency - Add `dateRange` property returning DateInterval - Add `contains(_:)` method to check if date falls within cycle - Add `cycle(for:)` static factory method - Change `expirationDate` to return exact expiration moment - Move factory methods to separate extension for SwiftLint compliance - Add comprehensive Cycle tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 28d8722 commit 8ef2536

6 files changed

Lines changed: 163 additions & 31 deletions

File tree

Sources/SwiftCIFP/CIFP.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ private struct CIFPBuilder {
558558
guard let c = Cycle(yymm: cycleStr) else { continue }
559559
return c
560560
}
561-
return Cycle.current
561+
return Cycle.effective
562562
}
563563

564564
private func buildAirways(errorCallback: ((Error, Int?) -> Void)?) -> [String: Airway] {

Sources/SwiftCIFP/CIFPData.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ public actor CIFPData {
160160
dataSupplier: "TEST",
161161
descriptiveText: []
162162
)
163-
self.cycle = .current
163+
self.cycle = .effective
164164
self.gridMORAs = []
165165
self.vhfNavaids = vhfNavaids
166166
self.ndbNavaids = ndbNavaids

Sources/SwiftCIFP/Cycle.swift

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ extension Cycle {
5050
/// AIRAC cycle length in days.
5151
private static let cycleLengthDays = 28
5252

53-
/// The current AIRAC cycle.
54-
public static var current: Self {
53+
/// The currently effective AIRAC cycle.
54+
public static var effective: Self {
5555
let calendar = Calendar(identifier: .gregorian)
5656
let now = Date()
5757

@@ -81,51 +81,65 @@ extension Cycle {
8181
)
8282
}
8383

84-
/// The expiration date of this cycle (day before next cycle).
84+
/// The expiration date of this cycle (midnight UTC when cycle expires).
85+
///
86+
/// This is the exact moment the cycle expires, which is also the effective date of the next cycle.
8587
public var expirationDate: Date? {
8688
guard let effectiveDate else { return nil }
8789
let calendar = Calendar(identifier: .gregorian)
88-
return calendar.date(byAdding: .day, value: Self.cycleLengthDays - 1, to: effectiveDate)
90+
return calendar.date(byAdding: .day, value: Self.cycleLengthDays, to: effectiveDate)
91+
}
92+
93+
/// The date range when this cycle is effective.
94+
///
95+
/// The range starts at `effectiveDate` and ends at `expirationDate` (exclusive).
96+
public var dateRange: DateInterval? {
97+
guard let effectiveDate, let expirationDate else { return nil }
98+
return DateInterval(start: effectiveDate, end: expirationDate)
99+
}
100+
101+
/// Returns whether the given date falls within this cycle's effective period.
102+
///
103+
/// - Parameter date: The date to check.
104+
/// - Returns: `true` if the date falls within this cycle's effective period.
105+
public func contains(_ date: Date) -> Bool {
106+
guard let dateRange else { return false }
107+
return dateRange.contains(date)
89108
}
90109
}
91110

92111
// MARK: - Cycle Navigation
93112

94113
extension Cycle {
95114
/// The previous cycle.
96-
public var previous: Self {
115+
public var previous: Self? {
97116
if cycleNumber > 1 {
98117
return Self(year: year, cycleNumber: cycleNumber - 1)
99118
}
100119
return Self(year: year - 1, cycleNumber: 13)
101120
}
102121

103122
/// The next cycle.
104-
public var next: Self {
123+
public var next: Self? {
105124
if cycleNumber < 13 {
106125
return Self(year: year, cycleNumber: cycleNumber + 1)
107126
}
108127
return Self(year: year + 1, cycleNumber: 1)
109128
}
110129

111130
/// Whether this cycle is currently effective.
112-
public var isCurrent: Bool {
113-
self == Self.current
131+
public var isEffective: Bool {
132+
self == Self.effective
114133
}
115134
}
116135

117136
// MARK: - String Representation
118137

119138
extension Cycle {
120-
/// The YYMM string representation.
121-
public var yymm: String {
139+
/// The YYMM string representation (used internally for identification).
140+
var yymm: String {
122141
String(format: "%02d%02d", year % 100, cycleNumber)
123142
}
124-
125-
/// The full 4-digit year.
126-
public var fullYear: Int {
127-
Int(year)
128-
}
129143
}
130144

131145
// MARK: - Comparable
@@ -152,3 +166,25 @@ extension Cycle: CustomStringConvertible {
152166
"AIRAC \(yymm)"
153167
}
154168
}
169+
170+
// MARK: - Factory Methods
171+
172+
extension Cycle {
173+
/// Returns the cycle that contains the given date.
174+
///
175+
/// - Parameter date: The date to find the cycle for.
176+
/// - Returns: The cycle containing the date, or `nil` if the cycle cannot be determined.
177+
public static func cycle(for date: Date) -> Self? {
178+
let calendar = Calendar(identifier: .gregorian)
179+
let daysSinceRef = calendar.dateComponents([.day], from: referenceDate, to: date).day ?? 0
180+
181+
// Handle dates before the reference
182+
guard daysSinceRef >= 0 else { return nil }
183+
184+
let totalCycles = daysSinceRef / cycleLengthDays
185+
let year = 2024 + (totalCycles / 13)
186+
let cycleInYear = (totalCycles % 13) + 1
187+
188+
return Self(year: UInt(year), cycleNumber: UInt8(cycleInYear))
189+
}
190+
}

Sources/SwiftCIFP/Documentation.docc/GettingStarted.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,11 @@ print("Data cycle: \(cifp.cycle)")
134134
print("Effective: \(cifp.cycle.effectiveDate)")
135135
print("Expires: \(cifp.cycle.expirationDate)")
136136

137-
// Get current AIRAC cycle
138-
let current = Cycle.current
139-
print("Current cycle: \(current.yymm)")
137+
// Get effective AIRAC cycle
138+
let effective = Cycle.effective
139+
print("Effective cycle: \(effective)")
140140

141141
// Navigate between cycles
142-
let next = current.next
143-
let previous = current.previous
142+
let next = effective.next
143+
let previous = effective.previous
144144
```

Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ struct SwiftCIFP_E2E: AsyncParsableCommand {
4040
/// URL to download the current CIFP cycle from the FAA.
4141
private var currentCycleURL: URL {
4242
get throws {
43-
let cycle = Cycle.current
43+
let cycle = Cycle.effective
4444
guard let effectiveDate = cycle.effectiveDate else {
4545
throw ValidationError("Failed to calculate current cycle effective date")
4646
}

Tests/SwiftCIFPTests/SwiftCIFPTests.swift

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -291,21 +291,117 @@ struct CycleTests {
291291
#expect(cycle?.effectiveDate != nil)
292292
}
293293

294-
@Test("Cycle yymm format")
295-
func yymmFormat() {
296-
let cycle = Cycle(yymm: "2506")
297-
#expect(cycle?.yymm == "2506")
294+
@Test("Cycle navigation with optional returns")
295+
func navigation() {
296+
let cycle = Cycle(yymm: "2501")
297+
#expect(cycle != nil)
298+
if let cycle {
299+
#expect(cycle.next?.cycleNumber == 2)
300+
#expect(cycle.next?.year == 2025)
301+
#expect(cycle.previous?.cycleNumber == 13)
302+
#expect(cycle.previous?.year == 2024)
303+
}
298304
}
299305

300-
@Test("Cycle navigation")
301-
func navigation() {
306+
@Test("Cycle effective returns currently effective cycle")
307+
func effectiveCycle() {
308+
let effective = Cycle.effective
309+
#expect(effective.isEffective)
310+
#expect(effective.cycleNumber >= 1 && effective.cycleNumber <= 13)
311+
}
312+
313+
@Test("Cycle cycle(for:) returns correct cycle")
314+
func cycleForDate() {
315+
// Use the reference date (Jan 25, 2024 is cycle 2401)
316+
var calendar = Calendar(identifier: .gregorian)
317+
calendar.timeZone = .gmt
318+
let refDate = calendar.date(
319+
from: DateComponents(timeZone: .gmt, year: 2024, month: 1, day: 25)
320+
)!
321+
322+
let cycle = Cycle.cycle(for: refDate)
323+
#expect(cycle != nil)
324+
#expect(cycle?.year == 2024)
325+
#expect(cycle?.cycleNumber == 1)
326+
}
327+
328+
@Test("Cycle dateRange covers full cycle")
329+
func dateRange() {
330+
let cycle = Cycle(yymm: "2501")
331+
#expect(cycle != nil)
332+
if let cycle {
333+
let dateRange = cycle.dateRange
334+
#expect(dateRange != nil)
335+
336+
// Duration should be 28 days
337+
let expectedDuration: TimeInterval = 28 * 24 * 60 * 60
338+
#expect(dateRange?.duration == expectedDuration)
339+
}
340+
}
341+
342+
@Test("Cycle contains returns true for date within cycle")
343+
func containsDateWithinCycle() {
344+
let cycle = Cycle(yymm: "2501")
345+
#expect(cycle != nil)
346+
if let cycle, let effectiveDate = cycle.effectiveDate {
347+
// A date 14 days into the cycle should be contained
348+
var calendar = Calendar(identifier: .gregorian)
349+
calendar.timeZone = .gmt
350+
let midCycleDate = calendar.date(byAdding: .day, value: 14, to: effectiveDate)!
351+
#expect(cycle.contains(midCycleDate))
352+
}
353+
}
354+
355+
@Test("Cycle contains returns false for date outside cycle")
356+
func containsDateOutsideCycle() {
357+
let cycle = Cycle(yymm: "2501")
358+
#expect(cycle != nil)
359+
if let cycle, let effectiveDate = cycle.effectiveDate {
360+
// A date 30 days after the effective date should not be contained
361+
var calendar = Calendar(identifier: .gregorian)
362+
calendar.timeZone = .gmt
363+
let afterDate = calendar.date(byAdding: .day, value: 30, to: effectiveDate)!
364+
#expect(!cycle.contains(afterDate))
365+
}
366+
}
367+
368+
@Test("Cycle expirationDate returns exact moment")
369+
func expirationDateExactMoment() {
302370
let cycle = Cycle(yymm: "2501")
303371
#expect(cycle != nil)
304372
if let cycle {
305-
#expect(cycle.next.cycleNumber == 2)
306-
#expect(cycle.next.year == 2025)
373+
let expirationDate = cycle.expirationDate
374+
#expect(expirationDate != nil)
375+
376+
// expirationDate should equal next cycle's effectiveDate
377+
#expect(expirationDate == cycle.next?.effectiveDate)
307378
}
308379
}
380+
381+
@Test("Cycle comparison")
382+
func comparison() {
383+
let older = Cycle(yymm: "2501")
384+
let newer = Cycle(yymm: "2502")
385+
let same = Cycle(yymm: "2501")
386+
#expect(older != nil && newer != nil && same != nil)
387+
if let older, let newer, let same {
388+
#expect(older < newer)
389+
#expect(newer > older)
390+
#expect(older == same)
391+
}
392+
}
393+
394+
@Test("Cycle isEffective returns true for effective cycle")
395+
func isEffective() {
396+
let effective = Cycle.effective
397+
#expect(effective.isEffective)
398+
399+
// A past cycle should not be effective
400+
let past = Cycle(yymm: "2401")
401+
#expect(past != nil)
402+
// Past cycle may or may not be effective depending on when test runs
403+
_ = past?.isEffective
404+
}
309405
}
310406

311407
// MARK: - PathTerminator Tests

0 commit comments

Comments
 (0)