import Foundation import Testing @testable import IPodSyncKit @Suite("vCard writing") struct VCardWriterTests { private func makeContact() -> IPodContact { IPodContact( id: "1", givenName: "Ada", familyName: "Lovelace", organization: "Analytical Engines, Ltd.", jobTitle: "Mathematician", phoneNumbers: [LabeledValue(label: "CELL", value: "+1 555 0100")], emailAddresses: [LabeledValue(label: "WORK", value: "ada@example.com")], postalAddresses: [ PostalAddress( label: "HOME", street: "12 Ockham Road", city: "London", postalCode: "SW1", country: "UK" ) ], birthday: DateComponents(year: 1815, month: 12, day: 10) ) } @Test("Emits a well-formed vCard 3.0") func shape() { let vcard = VCardWriter.makeVCard(for: makeContact()) let lines = vcard.components(separatedBy: "\r\n") #expect(lines.first == "BEGIN:VCARD") #expect(lines.contains("VERSION:3.0")) #expect(lines.contains("N:Lovelace;Ada;;;")) #expect(lines.contains("FN:Ada Lovelace")) #expect(lines.contains("TEL;TYPE=CELL:+1 555 0100")) #expect(lines.contains("EMAIL;TYPE=INTERNET,WORK:ada@example.com")) #expect(lines.contains("ADR;TYPE=HOME:;;12 Ockham Road;London;;SW1;UK")) #expect(lines.contains("BDAY:1815-12-10")) #expect(vcard.hasSuffix("END:VCARD\r\n")) } @Test("Never emits the properties that break the device") func omitsProblemProperties() { let vcard = VCardWriter.makeVCard(for: makeContact()).uppercased() #expect(!vcard.contains("PHOTO")) #expect(!vcard.contains("LOGO")) #expect(!vcard.contains("SOUND")) #expect(!vcard.contains("\r\nX-")) #expect(!vcard.contains("QUOTED-PRINTABLE")) } @Test("Commas and semicolons in values are escaped, not treated as structure") func escapesValues() { let vcard = VCardWriter.makeVCard(for: makeContact()) #expect(vcard.contains("ORG:Analytical Engines\\, Ltd.")) } @Test("Falls back to organization, then email, for the display name") func displayNameFallbacks() { let company = IPodContact(organization: "Acme") #expect(company.displayName == "Acme") let emailOnly = IPodContact( emailAddresses: [LabeledValue(label: "", value: "someone@example.com")] ) #expect(emailOnly.displayName == "someone@example.com") #expect(VCardWriter.makeVCard(for: emailOnly).contains("EMAIL;TYPE=INTERNET:")) } @Test("Year-less birthdays are dropped rather than invented") func yearlessBirthday() { #expect(VCardWriter.formatBirthday(DateComponents(month: 5, day: 12)) == nil) #expect(VCardWriter.formatBirthday(DateComponents(year: 1990, month: 5, day: 12)) == "1990-05-12") } } @Suite("iCalendar writing") struct ICalendarWriterTests { private let zone = TimeZone(identifier: "America/Toronto")! private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0, _ minute: Int = 0) -> Date { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = zone return calendar.date(from: DateComponents( year: year, month: month, day: day, hour: hour, minute: minute ))! } @Test("Times are floating — no Z, no TZID, no VTIMEZONE") func floatingTimes() { let event = IPodEvent( uid: "abc@ipod", summary: "Standup", start: date(2026, 8, 10, 9, 30), end: date(2026, 8, 10, 10, 0) ) let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone) #expect(ics.contains("DTSTART:20260810T093000")) #expect(ics.contains("DTEND:20260810T100000")) #expect(!ics.contains("TZID")) #expect(!ics.contains("VTIMEZONE")) #expect(!ics.contains("DTSTART:20260810T093000Z")) } @Test("Alarms and recurrence rules are structurally absent") func omitsProblemComponents() { let ics = ICalendarWriter.makeCalendar( events: [IPodEvent(uid: "a", summary: "x", start: date(2026, 8, 10), end: date(2026, 8, 11))], timeZone: zone ) #expect(!ics.contains("VALARM")) #expect(!ics.contains("RRULE")) #expect(!ics.contains("\r\nX-")) } @Test("All-day events use exclusive DATE ends") func allDayEnds() { // EventKit reports a one-day all-day event as ending at 23:59:59. let oneDay = IPodEvent( uid: "a", summary: "Holiday", start: date(2026, 8, 10), end: date(2026, 8, 10, 23, 59), isAllDay: true ) let ics = ICalendarWriter.makeCalendar(events: [oneDay], timeZone: zone) #expect(ics.contains("DTSTART;VALUE=DATE:20260810")) #expect(ics.contains("DTEND;VALUE=DATE:20260811")) } @Test("An all-day event already ending at midnight is not pushed a day further") func allDayMidnightEnd() { let event = IPodEvent( uid: "a", summary: "Holiday", start: date(2026, 8, 10), end: date(2026, 8, 11), isAllDay: true ) let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone) #expect(ics.contains("DTEND;VALUE=DATE:20260811")) } @Test("Zero-length events omit DTEND rather than emit an invalid range") func zeroLengthEvent() { let event = IPodEvent( uid: "a", summary: "Marker", start: date(2026, 8, 10, 9, 0), end: date(2026, 8, 10, 9, 0) ) let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone) #expect(ics.contains("DTSTART:20260810T090000")) #expect(!ics.contains("DTEND")) } @Test("Events are sorted and titles always present") func sortingAndTitles() { let later = IPodEvent(uid: "b", summary: "Later", start: date(2026, 8, 11), end: date(2026, 8, 11, 1)) let earlier = IPodEvent(uid: "a", summary: "", start: date(2026, 8, 10), end: date(2026, 8, 10, 1)) let ics = ICalendarWriter.makeCalendar(events: [later, earlier], timeZone: zone) let firstSummary = ics.range(of: "SUMMARY:")! #expect(ics[firstSummary.upperBound...].hasPrefix("(No title)")) #expect(ics.contains("SUMMARY:Later")) } }