用户把 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 scalar 累加,保证每条物理行都是有效 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。