Vérifier les exports de calendrier iOS sur un Mac cloud

Vérifier les exports de calendrier iOS sur un Mac cloud

Vérifier les exports de calendrier iOS sur un Mac cloud

Un utilisateur importe dans son calendrier un événement exporté par votre app : les virgules du titre ont disparu et l’événement sur toute la journée s’étend sur un jour de trop. Ce type de problème ne vient généralement pas d’un échec de compilation, mais d’un exporteur qui traite les chaînes de caractères, les octets et les dates comme s’il s’agissait des mêmes données. Sur un Mac cloud, vous pouvez exécuter des cas de test fixes en Swift, examiner d’abord le fichier ICS, puis vérifier le résultat de son import, sans attendre les captures d’écran des utilisateurs.

Définir d’abord le périmètre de validation

Nous nous limitons ici à l’export d’un événement unique : le titre et la description sont du texte, les événements horaires utilisent l’UTC et les événements sur toute la journée utilisent des dates. Les règles de récurrence, les invitations et les rappels sont hors périmètre. Si vous ajoutez ces fonctions par la suite, prévoyez des cas de test distincts : « le fichier s’ouvre » ne suffit pas comme critère de validation.

Fixez l’UID, le DTSTAMP et les dates d’entrée de l’événement. En production, l’UID doit être conservé avec l’événement et rester stable, plutôt que d’être régénéré à chaque export. Les clients de calendrier ne gèrent pas tous de la même façon les imports répétés ; un UID stable ne garantit donc pas une mise à jour automatique.

Le contrôle du format répond à la question « le fichier respecte-t-il les conventions ? », tandis que le contrôle de l’import répond à « l’événement affiché à l’utilisateur est-il correct ? ». Aucun des deux ne remplace l’autre.

Dans le répertoire de travail, enregistrez le script suivant sous le nom calendar-check.swift. Il utilise uniquement Foundation et peut être exécuté une fois les outils en ligne de commande Xcode installés et sélectionnés.

Replier les lignes selon les octets, sans tronquer les caractères

Dans les valeurs textuelles ICS, il faut échapper les barres obliques inverses, les points-virgules, les virgules et les sauts de ligne. Commencez par normaliser les fins de ligne de l’entrée, appliquez ensuite l’échappement, puis repliez les lignes. Inverser cet ordre risque d’échapper aussi les marqueurs de continuation ou de traiter à tort une séquence d’échappement comme du texte ordinaire.

Visez une longueur maximale de 75 octets par ligne physique, sans compter CRLF. Chaque ligne de continuation commence par une espace, qui compte elle aussi pour un octet. N’utilisez pas String.count pour calculer cette longueur et ne tronquez jamais directement un tableau d’octets UTF-8 : les caractères chinois et les emoji pourraient alors devenir des séquences invalides.

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
)

L’accumulation se fait ici par valeur scalaire Unicode, ce qui garantit que chaque ligne physique contient du UTF-8 valide. Un emoji composé peut être réparti sur deux lignes physiques, mais la suppression des marqueurs de continuation doit restituer le texte d’origine. Le contrôle de cohérence après dépliage sert précisément à détecter toute perte ou tout ajout de caractères lors du repli. Il vérifie le générateur de cet exemple ; ce n’est pas un analyseur ICS générique.

Distinguer les instants absolus des dates de journée entière

Les événements horaires utilisent des valeurs UTC terminées par Z, qui désignent des instants précis. Lorsque l’entrée provient d’un calendrier local, interprétez-la d’abord dans le fuseau horaire choisi par l’utilisateur pour obtenir un instant, puis formatez-la. Il ne suffit pas d’ajouter un Z à une chaîne représentant une heure locale.

À l’inverse, un événement sur toute la journée ne doit pas être converti au préalable en minuit UTC. Remplacez les propriétés de début et de fin de l’exemple par :

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

Ces valeurs représentent la seule journée du 30 septembre. La date de fin est exclue de l’événement ; cette règle reste la même lors d’un changement de mois ou d’année. Pour calculer le lendemain, utilisez des opérations sur les dates avec un calendrier explicitement défini, plutôt que d’ajouter un nombre fixe de secondes à un horodatage.

Si le produit doit exprimer « tous les jours à neuf heures, heure locale », un événement UTC unique ne suffit pas. Il faut concevoir séparément la gestion de l’identifiant de fuseau horaire, de sa définition et des règles de récurrence, sans laisser le client de calendrier les deviner à l’import.

Vérifier la sémantique à partir du résultat de l’import

Exécutez le script, rapatriez le fichier généré sur votre machine locale, puis importez-le dans un calendrier réservé aux tests :

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

Après le transfert, recalculez l’empreinte pour confirmer que vous testez bien le même fichier. Le fait de pouvoir afficher un fichier dans une session de bureau à distance ne prouve ni que son transfert est correct, ni que son contenu l’est.

Vérifiez chaque point : les caractères chinois et les emoji sont-ils intacts ? Les virgules, les points-virgules et les barres obliques inverses sont-ils toujours présents ? La description comporte-t-elle exactement deux lignes ? L’événement horaire dure-t-il une heure ? L’événement sur toute la journée ne couvre-t-il qu’un jour ? Effectuez cette validation au moins une fois dans chacun des clients réellement pris en charge par le projet. La tolérance d’un client aux erreurs ne constitue pas une norme de format.

Ajoutez également des cas avec un titre vide, une description très longue, un événement sur toute la journée à cheval sur deux mois et un même événement UTC affiché dans différents fuseaux horaires. Lors des vérifications manuelles des heures, notez aussi le fuseau horaire du client pour ne pas confondre une conversion correcte avec un décalage erroné.

Transformer les échecs en éléments de diagnostic

Dans la CI, conservez le fichier généré, l’identifiant du cas d’entrée, la version du script et le code de sortie de son exécution. Si un contrôle échoue, bloquez la livraison : ne capturez pas l’erreur pour poursuivre ensuite l’envoi d’un ancien fichier. Les titres et descriptions d’événements réels peuvent contenir des informations privées ; les artefacts de régression ne doivent donc contenir que des données synthétiques.

Organisez les contrôles bloquants en trois niveaux : au niveau des octets, vérifiez CRLF et la cohérence après dépliage ; au niveau des propriétés, vérifiez les champs obligatoires et les types de dates ; au niveau sémantique, vérifiez les dates, la durée et le texte après import. Lors de l’ajout d’événements récurrents ou de rappels, précisez à quel niveau ajouter les règles et quel cas de test en échec doit les couvrir.

Enfin, introduisez un contre-exemple minimal dans l’exporteur : supprimez l’échappement des virgules, remplacez la date de fin d’un événement sur toute la journée par sa date de début, ou tronquez une séquence UTF-8 en plein milieu. Les tests doivent détecter ces défauts pour protéger réellement le contrat d’export, et pas seulement confirmer que l’implémentation actuelle s’exécute.

Questions fréquentes

Le repli des lignes ICS se calcule-t-il sur 75 caractères ?

La recommandation porte sur 75 octets, pas sur 75 caractères. Les caractères UTF-8 peuvent occuper plusieurs octets et l’espace initial d’une ligne de continuation compte aussi.

DTEND désigne-t-il le dernier jour inclus d’un événement ?

Non. DTEND est exclusif. Un événement d’une journée se termine à la date suivante ; DTSTART et DTEND utilisent tous deux VALUE=DATE.

Lancez votre prochain build sur une station de travail dans le cloud

Un Mac mini entier rien que pour vous, sans virtualisation. Choisissez la durée selon votre projet ; la disponibilité réelle est indiquée en temps réel dans la console.

Louer un Mac dans le cloud

Vérifiez la configuration avant de planifier votre projet

Consultez les cycles de facturation et les options de Desk M4, ou préparez votre première connexion avec le guide de démarrage.