Как проверить экспорт календаря iOS в ICS на облачном Mac
Пользователь импортирует в календарь события, экспортированные из приложения, — и обнаруживает, что из заголовков исчезли запятые, а события на весь день занимают лишний день. Обычно причина не в сбое сборки, а в том, что экспортёр не различает строки, байты и даты. На облачном Mac можно запускать фиксированные тестовые примеры на Swift: сначала проверять сам ICS-файл, затем — результат его импорта, не дожидаясь скриншотов от пользователей.
Сначала определите границы проверки
Здесь рассматривается только экспорт отдельного события: заголовок и описание — текст, события с указанием времени используют UTC, а события на весь день — календарные даты. Правила повторения, приглашения участникам и напоминания в проверку не входят. Если эти функции появятся позже, для них понадобятся отдельные тестовые примеры: критерия «файл открывается — значит, всё в порядке» недостаточно.
Зафиксируйте UID, DTSTAMP и входные даты события. В рабочем приложении UID должен сохраняться вместе с событием, а не генерироваться заново при каждом экспорте. Разные календарные клиенты по-разному обрабатывают повторный импорт, поэтому стабильный UID сам по себе не гарантирует автоматического обновления.
Проверка формата отвечает на вопрос «соответствует ли файл заданным требованиям», а проверка импорта — «правильно ли отображается событие у пользователя». Одна проверка не заменяет другую.
Сохраните приведённый ниже скрипт в рабочем каталоге под именем calendar-check.swift. Он использует только Foundation; для запуска достаточно установить и выбрать инструменты командной строки Xcode.
Сворачивайте строки по байтам, а не обрезайте по символам
В текстовых значениях ICS нужно экранировать обратную косую черту, точку с запятой, запятую и перевод строки. Сначала нормализуйте переводы строк во входных данных, затем выполните экранирование и только после этого — сворачивание строк. Если изменить порядок, можно случайно экранировать маркеры продолжения строки или принять escape-последовательности за обычный текст.
При сворачивании рекомендуется ограничивать каждую физическую строку 75 октетами, не считая CRLF. Строка продолжения начинается с одного пробела, который тоже занимает один байт. Не рассчитывайте вместимость через String.count и тем более не обрезайте массив UTF-8 напрямую: так можно получить некорректную кодировку китайских символов и эмодзи.
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
)
Здесь строка собирается по скалярным значениям Unicode, поэтому каждая физическая строка остаётся корректной последовательностью UTF-8. Составной эмодзи может оказаться разделённым между двумя физическими строками, но после удаления маркеров продолжения исходный текст должен полностью восстановиться. Проверка совпадения после разворачивания нужна именно для того, чтобы обнаружить потерянные или добавленные символы. Она проверяет генератор из этого примера, а не служит универсальным парсером ICS.
Разделяйте абсолютное время и даты событий на весь день
Для событий с указанием времени используйте значения UTC с суффиксом Z: они задают конкретный момент времени. Если входные данные поступают из локального календаря, сначала определите момент времени с учётом выбранного пользователем часового пояса и лишь затем форматируйте его. Просто дописать Z к строке местного времени нельзя.
События на весь день, напротив, не следует предварительно преобразовывать в полночь UTC. Замените свойства начала и окончания в примере выше на:
DTSTART;VALUE=DATE:20260930
DTEND;VALUE=DATE:20261001
Это событие на один день — 30 сентября. Дата окончания в событие не входит; то же правило действует при переходе на следующий месяц или год. Вычисляйте следующий день средствами календарной арифметики с явно заданным календарём, а не прибавляйте к временной метке фиксированное число секунд.
Если продукт должен поддерживать сценарий «каждый день в девять утра по местному времени», одиночного события в UTC недостаточно. Для этого требуется отдельно спроектировать идентификатор часового пояса, его определение и правила повторения, а не полагаться на догадки клиента при импорте.
Проверяйте смысл события по результату импорта
Запустите скрипт, затем перенесите созданный файл на локальный компьютер и импортируйте его в отдельный тестовый календарь:
xcrun swift calendar-check.swift
shasum -a 256 calendar-check.ics
После передачи снова вычислите контрольную сумму, чтобы убедиться, что проверяете тот же самый файл. Возможность просмотреть файл через удалённый рабочий стол не доказывает ни корректность передачи, ни правильность содержимого.
Проверьте каждый пункт: сохранились ли китайские символы и эмодзи; остались ли запятые, точки с запятой и обратные косые черты; занимает ли описание ровно две строки; длится ли событие с указанием времени один час; охватывает ли событие на весь день только один день. Проведите хотя бы одну такую проверку в каждом клиенте, который действительно поддерживает проект. Не принимайте терпимость одного клиента к ошибкам за требования формата.
Добавьте примеры с пустым заголовком, очень длинным описанием, событием на весь день с переходом между месяцами и отображением одного и того же события UTC в разных часовых поясах. При ручной проверке времени записывайте также часовой пояс клиента, чтобы не принять правильный пересчёт за ошибочное смещение.
Сохраняйте данные, по которым можно найти причину сбоя
В CI сохраняйте сгенерированный файл, идентификатор входного примера, версию скрипта и код завершения. Если проверка не прошла, останавливайте поставку: нельзя перехватывать ошибку и продолжать загрузку старого файла. Заголовки и описания реальных событий могут содержать личные данные, поэтому в артефактах регрессионных проверок должны оставаться только синтетические данные.
Разделите контроль на три уровня: на уровне байтов проверяйте CRLF и совпадение после разворачивания строк; на уровне свойств — обязательные поля и типы дат; на семантическом уровне — даты, длительность и текст после импорта. При добавлении повторяющихся событий или напоминаний явно указывайте, какой уровень получает новое правило и какой пример демонстрирует сбой.
Наконец, добавьте минимальный негативный пример для экспортёра: уберите экранирование запятой, замените дату окончания события на весь день на дату его начала или обрежьте данные посередине последовательности UTF-8. Только если тесты обнаруживают такие дефекты, они действительно защищают контракт экспорта, а не просто подтверждают, что текущая реализация запускается.
Часто задаваемые вопросы
При переносе строк ICS нужно считать 75 символов?
Рекомендация относится к 75 октетам, а не символам. Кириллица и эмодзи занимают несколько байтов UTF-8; начальный пробел строки продолжения тоже учитывается.
В DTEND события на весь день указывают последний день?
Нет. Дата DTEND не входит в событие. Для однодневного события указывают следующий день, а DTSTART и DTEND записывают с VALUE=DATE.
Перенесите следующую сборку на облачный Mac
Вам выделяется целый Mac mini — не виртуальная машина. Выберите срок аренды под задачу; актуальный статус доступности смотрите в консоли.
Арендовать облачный Mac