雲端 Mac 上檢核 iOS 行事曆匯出:ICS 跳脫與日期邊界
使用者將 App 匯出的行程匯入行事曆後,標題裡的逗號不見了,全天事件還多占了一天。這類問題通常不是建置失敗,而是匯出器把字串、位元組和日期當成同一種資料處理。你可以在雲端 Mac 上用 Swift 執行固定測試案例,先檢查 ICS 檔案,再核對實際匯入結果,不必等使用者傳來截圖。
先界定驗收範圍
這次只處理單一事件的匯出:標題與描述是文字,指定時間的事件使用 UTC,全天事件則使用日期。不涵蓋重複規則、與會邀請和提醒。日後若加入這些功能,應另建測試案例,不能沿用「檔案打得開就算通過」的標準。
將事件的 UID、DTSTAMP 和輸入日期固定下來。在正式環境中,UID 應隨事件一併保存並維持不變,不能每次匯出都重新產生;不同行事曆用戶端處理重複匯入的方式各異,UID 穩定也不代表一定會自動更新。
格式檢查回答的是「檔案是否符合約定」,匯入檢查回答的則是「使用者看到的事件是否正確」。兩者不能互相取代。
在工作目錄中,將下列腳本儲存為 calendar-check.swift。它只使用 Foundation,安裝並選定 Xcode 命令列工具後即可執行。
按位元組折行,不要按字元截斷
ICS 文字值中的反斜線、分號、逗號和換行都需要跳脫。先統一輸入的換行格式,再進行跳脫,最後才折行。順序顛倒,可能連續行標記也一起跳脫,或把跳脫序列誤當成內文。
折行時,建議讓每條實體行不超過 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 剖析器。
區分絕對時間與全天日期
指定時間的事件使用帶有 Z 的 UTC 值,表示明確的時間點。輸入來自本地行事曆時,應先搭配使用者選定的時區解析成時間點,再進行格式化,不能只在本地時間字串後面加上 Z。
全天事件則不應先轉換成 UTC 午夜。請將上例的開始與結束屬性替換為:
DTSTART;VALUE=DATE:20260930
DTEND;VALUE=DATE:20261001
這代表九月三十日這一天。結束日期不包含在事件範圍內;跨月、跨年也遵循相同規則。程式計算次日日期時,應使用明確指定曆法的日期運算,不要直接在某個時間戳記上加上固定秒數。
如果產品要求「當地每天上午九點」,單次 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。