import Foundation import Testing @testable import IPodSyncKit @Suite("Content line folding and escaping") struct ContentLineTests { @Test("Short lines are left alone") func shortLineUnchanged() { #expect(ContentLine.fold("FN:Ada Lovelace") == "FN:Ada Lovelace") } @Test("Long lines fold to CRLF + space, within the octet limit") func longLineFolds() { let line = "NOTE:" + String(repeating: "a", count: 300) let folded = ContentLine.fold(line) #expect(folded.contains("\r\n ")) for piece in folded.components(separatedBy: "\r\n") { #expect(piece.utf8.count <= ContentLine.octetLimit) } // Unfolding is lossless. #expect(ContentLine.unfold(folded) == [line]) } @Test("Folding never splits a multi-byte character") func foldingRespectsUTF8Boundaries() { // Four-byte scalars, so a naive byte split lands mid-character. let line = "NOTE:" + String(repeating: "🎧", count: 60) let folded = ContentLine.fold(line) for piece in folded.components(separatedBy: "\r\n") { #expect(piece.utf8.count <= ContentLine.octetLimit) // A replacement character would mean we cut a scalar in half. #expect(!piece.contains("\u{FFFD}")) } #expect(ContentLine.unfold(folded) == [line]) } @Test("A single character wider than the budget is emitted rather than looping") func oversizedCharacterTerminates() { let line = String(repeating: "🎧", count: 2) #expect(ContentLine.fold(line) == line) } @Test("TEXT values escape backslash, semicolon, comma and newline") func escaping() { #expect(ContentLine.escapeText("a;b,c\\d") == "a\\;b\\,c\\\\d") #expect(ContentLine.escapeText("line1\nline2") == "line1\\nline2") #expect(ContentLine.escapeText("crlf\r\nhere") == "crlf\\nhere") } @Test("Unfolding accepts CRLF, LF and bare CR") func unfoldingLineEndings() { #expect(ContentLine.unfold("A:1\r\nB:2") == ["A:1", "B:2"]) #expect(ContentLine.unfold("A:1\nB:2") == ["A:1", "B:2"]) #expect(ContentLine.unfold("A:1\rB:2") == ["A:1", "B:2"]) #expect(ContentLine.unfold("A:12\r\n 34") == ["A:1234"]) #expect(ContentLine.unfold("A:12\r\n\t34") == ["A:1234"]) } @Test("Rendered documents end with CRLF") func builderRendersCRLF() { var builder = ContentLineBuilder() builder.appendVerbatim("BEGIN", "VCARD") builder.appendVerbatim("END", "VCARD") #expect(builder.render() == "BEGIN:VCARD\r\nEND:VCARD\r\n") } } @Suite("Filename allocation") struct FileNamingTests { @Test("Accents fold to ASCII and unsafe characters are dropped") func sanitizing() { var allocator = FileNameAllocator() #expect(allocator.allocate(preferred: "Renée Fleming", fileExtension: "vcf") == "Renee Fleming.vcf") var other = FileNameAllocator() #expect(other.allocate(preferred: "A/B:C*D", fileExtension: "vcf") == "A B C D.vcf") } @Test("Empty and dot-only names fall back") func fallbacks() { var allocator = FileNameAllocator() #expect(allocator.allocate(preferred: "", fileExtension: "vcf") == "item.vcf") #expect(allocator.allocate(preferred: "...", fallback: "contact", fileExtension: "vcf") == "contact.vcf") } @Test("Repeated names are numbered") func deduplication() { var allocator = FileNameAllocator() #expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith.vcf") #expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith_2.vcf") // Matching is case-insensitive, since the device's volume is usually FAT32. #expect(allocator.allocate(preferred: "john smith", fileExtension: "vcf") == "john smith_3.vcf") } @Test("Names are capped, including their numbering suffix") func lengthCap() { var allocator = FileNameAllocator() let long = String(repeating: "x", count: 200) let first = allocator.allocate(preferred: long, fileExtension: "vcf") let second = allocator.allocate(preferred: long, fileExtension: "vcf") #expect(first.dropLast(4).count == FileNameAllocator.maximumLength) #expect(second.dropLast(4).count <= FileNameAllocator.maximumLength) #expect(second != first) } }