iOS-Kalenderexporte auf dem Cloud-Mac prüfen

iOS-Kalenderexporte auf dem Cloud-Mac prüfen

iOS-Kalenderexporte auf dem Cloud-Mac prüfen

Ein Nutzer importiert die aus einer App exportierten Termine in seinen Kalender: Plötzlich fehlen die Kommas im Titel, und ganztägige Termine belegen einen Tag zu viel. Dahinter steckt meist kein Build-Fehler, sondern ein Exporter, der Zeichenketten, Bytes und Datumswerte nicht sauber auseinanderhält. Auf einem Cloud-Mac lassen sich feste Testfälle mit Swift ausführen: erst die ICS-Datei prüfen, dann das tatsächliche Importergebnis kontrollieren — ohne auf Screenshots der Nutzer warten zu müssen.

Zuerst den Prüfumfang festlegen

Hier geht es ausschließlich um den Export eines einzelnen Termins: Titel und Beschreibung sind Text, Termine mit Uhrzeit verwenden UTC, ganztägige Termine reine Datumswerte. Wiederholungsregeln, Einladungen an Teilnehmer und Erinnerungen sind nicht Teil dieser Prüfung. Werden diese Funktionen später ergänzt, brauchen sie eigene Testfälle. „Die Datei lässt sich öffnen“ reicht auch dann nicht als Abnahmekriterium.

Halten Sie UID, DTSTAMP und die Eingabedaten des Termins für den Test konstant. Im Produktivbetrieb muss die UID dauerhaft zum Termin gespeichert werden, statt sie bei jedem Export neu zu erzeugen. Kalenderprogramme behandeln wiederholte Importe unterschiedlich; eine stabile UID allein garantiert deshalb keine automatische Aktualisierung.

Die Formatprüfung beantwortet die Frage „Entspricht die Datei den festgelegten Anforderungen?“, die Importprüfung dagegen „Sieht der Nutzer den richtigen Termin?“. Keine der beiden Prüfungen ersetzt die andere.

Speichern Sie das folgende Skript im Arbeitsverzeichnis als calendar-check.swift. Es verwendet ausschließlich Foundation und lässt sich ausführen, sobald die Xcode-Kommandozeilenwerkzeuge installiert und ausgewählt sind.

Zeilen nach Bytes falten, nicht nach Zeichen abschneiden

In ICS-Textwerten müssen Backslashes, Semikolons, Kommas und Zeilenumbrüche maskiert werden. Vereinheitlichen Sie zuerst die Zeilenumbrüche der Eingabe, maskieren Sie anschließend die Sonderzeichen und falten Sie erst danach die Zeilen. Bei umgekehrter Reihenfolge können versehentlich auch die Fortsetzungsmarkierungen maskiert oder Escape-Sequenzen als normaler Text behandelt werden.

Bei der Zeilenfaltung sollte jede physische Zeile höchstens 75 Oktette umfassen, ohne CRLF. Fortsetzungszeilen beginnen mit einem Leerzeichen, das ebenfalls ein Byte belegt. Verwenden Sie für die Längenberechnung nicht String.count und schneiden Sie keinesfalls direkt ein UTF-8-Array ab. Sonst können chinesische Schriftzeichen und Emojis zu ungültigen Bytefolgen werden.

import Foundation

func escapeText(_ value: String) -> String {
    value
        .replacingOccurrences(of: "\r\n", with: "\n")
        .replacingOccurrences(of: "\r", with: "\n")
        .replacingOccurrences(of: "\\", with: "\\\\")
        .replacingOccurrences(of: ";", with: "\\;")
        .replacingOccurrences(of: ",", with: "\\,")
        .replacingOccurrences(of: "\n", with: "\\n")
}

func fold(_ value: String) -> String {
    var lines: [String] = []
    var current = ""
    var bytes = 0

    for scalar in value.unicodeScalars {
        let piece = String(scalar)
        let size = piece.utf8.count
        if bytes + size > 75 {
            lines.append(current)
            current = " "
            bytes = 1
        }
        current += piece
        bytes += size
    }

    lines.append(current)
    return lines.joined(separator: "\r\n")
}

let title = String(repeating: "项目复盘🧑🏽‍💻", count: 12)
let description = "范围,A;B\\C\r\n第二行"
let logicalLines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//MiniVPS//Calendar Check//ZH",
    "BEGIN:VEVENT",
    "UID:calendar-check-001",
    "DTSTAMP:20260929T000000Z",
    "DTSTART:20260930T010000Z",
    "DTEND:20260930T020000Z",
    "SUMMARY:" + escapeText(title),
    "DESCRIPTION:" + escapeText(description),
    "END:VEVENT",
    "END:VCALENDAR"
]

let output = logicalLines.map(fold).joined(separator: "\r\n") + "\r\n"
let physicalLines = output.components(separatedBy: "\r\n")
precondition(physicalLines.last == "")
precondition(physicalLines.dropLast().allSatisfy {
    !$0.isEmpty && $0.utf8.count <= 75
})

let withoutCRLF = output.replacingOccurrences(of: "\r\n", with: "")
precondition(!withoutCRLF.contains("\r"))
precondition(!withoutCRLF.contains("\n"))

let unfolded = output.replacingOccurrences(of: "\r\n ", with: "")
precondition(unfolded == logicalLines.joined(separator: "\r\n") + "\r\n")
precondition(escapeText(description) == "范围\\,A\\;B\\\\C\\n第二行")

try Data(output.utf8).write(
    to: URL(fileURLWithPath: "calendar-check.ics"),
    options: .atomic
)

Das Skript baut die Zeilen aus Unicode-Skalarwerten auf. Dadurch bleibt jede physische Zeile gültiges UTF-8. Zusammengesetzte Emojis können auf zwei physische Zeilen verteilt werden; nach dem Entfernen der Fortsetzungsmarkierungen muss jedoch wieder exakt der ursprüngliche Text vorliegen. Die Konsistenzprüfung nach dem Entfalten soll sicherstellen, dass bei der Faltung keine Zeichen verloren gehen oder hinzukommen. Sie prüft den Generator dieses Beispiels und ist kein allgemeiner ICS-Parser.

Absolute Zeitpunkte und ganztägige Datumswerte trennen

Termine mit Uhrzeit verwenden UTC-Werte mit Z, die einen eindeutigen Zeitpunkt angeben. Stammen die Eingaben aus einem lokalen Kalender, müssen sie zunächst unter Berücksichtigung der vom Nutzer gewählten Zeitzone in einen Zeitpunkt umgewandelt und erst danach formatiert werden. Einfach ein Z an eine Zeichenkette mit lokaler Uhrzeit anzuhängen, ist nicht zulässig.

Ganztägige Termine sollten dagegen nicht zuerst in Mitternacht UTC umgerechnet werden. Ersetzen Sie die Eigenschaften für Beginn und Ende aus dem obigen Beispiel durch:

DTSTART;VALUE=DATE:20260930
DTEND;VALUE=DATE:20261001

Das steht für einen einzigen Tag, den 30. September. Das Enddatum gehört nicht mehr zum Termin; dieselbe Regel gilt auch über Monats- und Jahresgrenzen hinweg. Berechnen Sie den Folgetag mit Datumsoperationen auf Basis eines ausdrücklich festgelegten Kalenders, statt zu einem Zeitstempel eine feste Anzahl von Sekunden zu addieren.

Soll das Produkt „jeden Tag um neun Uhr morgens Ortszeit“ abbilden, reicht ein einzelner UTC-Termin nicht aus. Dafür müssen Zeitzonenkennung, Zeitzonendefinition und Wiederholungsregeln gesondert entworfen werden. Das darf nicht dem importierenden Kalenderprogramm zur Interpretation überlassen bleiben.

Die Semantik anhand des Importergebnisses prüfen

Führen Sie das Skript aus, übertragen Sie die erzeugte Datei anschließend auf Ihren lokalen Rechner und importieren Sie sie in einen eigens für Tests angelegten Kalender:

xcrun swift calendar-check.swift
shasum -a 256 calendar-check.ics

Berechnen Sie nach der Übertragung erneut die Prüfsumme, um sicherzustellen, dass Sie dieselbe Datei testen. Dass sich eine Datei per Remotedesktop anzeigen lässt, belegt weder eine korrekte Übertragung noch einen korrekten Inhalt.

Prüfen Sie jeden Punkt einzeln: Sind chinesische Schriftzeichen und Emojis vollständig erhalten? Sind Kommas, Semikolons und Backslashes noch vorhanden? Besteht die Beschreibung aus genau zwei Zeilen? Dauert der Termin mit Uhrzeit eine Stunde? Belegt der ganztägige Termin nur einen Tag? Führen Sie diese Abnahme mindestens einmal in jedem Kalenderprogramm durch, das Ihr Projekt tatsächlich unterstützt. Die Fehlertoleranz eines einzelnen Programms ist kein Maßstab für das Dateiformat.

Ergänzen Sie Testfälle mit leerem Titel, sehr langer Beschreibung, einem ganztägigen Termin über eine Monatsgrenze sowie der Anzeige desselben UTC-Termins in unterschiedlichen Zeitzonen. Notieren Sie bei der manuellen Zeitprüfung auch die Zeitzone des Kalenderprogramms, damit eine korrekte Umrechnung nicht fälschlich als Zeitverschiebungsfehler gewertet wird.

Fehler mit nachvollziehbaren Belegen eingrenzen

Bewahren Sie in der CI die erzeugte Datei, die Kennung des Eingabetestfalls, die Skriptversion und den Exitcode auf. Schlägt eine Prüfung fehl, muss die Auslieferung stoppen. Fangen Sie den Fehler nicht ab, um anschließend eine alte Datei hochzuladen. Titel und Beschreibungen realer Termine können private Informationen enthalten; Regressionsartefakte dürfen deshalb nur synthetische Daten enthalten.

Teilen Sie die Freigabeprüfung in drei Ebenen auf: Auf Byte-Ebene werden CRLF und die Übereinstimmung nach dem Entfalten geprüft, auf Eigenschaftsebene die Pflichtfelder und Datumstypen, auf semantischer Ebene Datum, Dauer und Text nach dem Import. Wenn wiederkehrende Termine oder Erinnerungen hinzukommen, halten Sie ausdrücklich fest, auf welcher Ebene welche Regel ergänzt wird und welcher Testfall den Fehler nachweist.

Fügen Sie dem Exporter abschließend einen minimalen Negativtest hinzu: Entfernen Sie die Maskierung eines Kommas, setzen Sie das Enddatum eines ganztägigen Termins auf dessen Startdatum oder schneiden Sie mitten in einer UTF-8-Sequenz ab. Erst wenn die Tests solche Fehler erkennen, sichern sie tatsächlich den Exportvertrag ab — statt nur zu bestätigen, dass die aktuelle Implementierung ausführbar ist.

Häufig gestellte Fragen

Sind bei der ICS-Zeilenfaltung 75 Zeichen gemeint?

Die Empfehlung bezieht sich auf 75 Oktette, nicht auf Zeichen. Mehrbytezeichen benötigen mehr UTF-8-Bytes; das führende Leerzeichen einer Fortsetzungszeile zählt mit.

Ist DTEND bei ganztägigen Terminen der letzte enthaltene Tag?

Nein. DTEND ist exklusiv. Ein eintägiger Termin endet am Folgetag; DTSTART und DTEND erhalten beide VALUE=DATE.

Den nächsten Build auf einer Cloud-Mac-Workstation ausführen

Ein ganzer Mac mini exklusiv für Sie – keine virtuelle Maschine. Wählen Sie die Laufzeit passend zu Ihrem Vorhaben. Ob die Workstation verfügbar ist, sehen Sie in Echtzeit in der Konsole.

Cloud-Mac jetzt mieten

Erst die Konfiguration prüfen, dann loslegen

Informieren Sie sich über Abrechnungszeiträume und Zusatzoptionen für Desk M4 oder richten Sie Ihre erste Verbindung mit der Einrichtungsanleitung ein.