クラウドMacでiOSカレンダー出力を検証する
アプリから書き出した予定をカレンダーに読み込むと、タイトルのカンマが消えたり、終日予定が一日余分に表示されたりする。こうした問題は、たいていビルドの失敗ではなく、エクスポーターが文字列、バイト、日付を区別せずに扱っていることが原因です。クラウドMac上でSwiftを使って固定のテストデータを処理し、まずICSファイルを検査してから、実際の読み込み結果を確認しましょう。ユーザーからスクリーンショットが届くのを待つ必要はありません。
検証の対象を絞る
今回は、単一イベントの書き出しだけを対象にします。タイトルと説明はテキスト、時刻指定の予定はUTC、終日予定は日付として扱います。繰り返しルール、参加者への招待、通知は含めません。後からこれらの機能を追加する場合は、別のテストデータを用意してください。「ファイルを開ければ合格」という基準を使い回してはいけません。
イベントのUID、DTSTAMP、入力日付は固定します。本番環境では、UIDをイベントにひも付けて保持し、書き出すたびに生成し直さないようにします。ただし、同じイベントを再度読み込んだときの挙動はカレンダークライアントによって異なり、UIDが安定しているだけで自動更新が保証されるわけではありません。
形式の検査で確認するのは「ファイルが仕様を満たしているか」、読み込みの検査で確認するのは「ユーザーに正しい予定が表示されるか」です。一方で他方を代用することはできません。
以下のスクリプトを作業ディレクトリに calendar-check.swift という名前で保存します。使用するのはFoundationだけなので、Xcodeコマンドラインツールをインストールして選択すれば実行できます。
文字数ではなくバイト数で折り返す
ICSのテキスト値では、バックスラッシュ、セミコロン、カンマ、改行をエスケープする必要があります。まず入力の改行を統一し、次にエスケープし、最後に行を折り返します。順序を逆にすると、継続行のマーカーまでエスケープしたり、エスケープシーケンスを本文の一部として誤って処理したりするおそれがあります。
折り返しは、CRLFを除く各物理行が75オクテット以内になることを目安にします。継続行の先頭にはスペースを1つ置き、このスペースも1バイトとして数えます。容量の計算に 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になるようにしています。複数の要素からなる絵文字が2つの物理行に分かれることはありますが、継続行のマーカーを取り除けば元の文字列に戻らなければなりません。スクリプト内の折り返し解除後の一致検査は、折り返しによる文字の欠落や余分な文字の混入を検出するためのものです。検証しているのはこのサンプルの生成処理であり、汎用のICSパーサーではありません。
絶対時刻と終日の日付を分ける
時刻指定の予定には、末尾が Z のUTC値を使い、特定の時点を表します。入力がローカルのカレンダーから取得されたものなら、ユーザーが選択したタイムゾーンと合わせて時点を確定してから書式化します。ローカル時刻の文字列の末尾に Z を付けるだけではいけません。
一方、終日予定をいったんUTCの午前0時に変換するべきではありません。上の例の開始・終了プロパティを次のように置き換えます。
DTSTART;VALUE=DATE:20260930
DTEND;VALUE=DATE:20261001
これは9月30日の一日を表します。終了日は予定の範囲に含まれません。月や年をまたぐ場合も同じルールです。プログラムで翌日を求めるときは、使用する暦を明示した日付演算を使い、タイムスタンプに固定の秒数を直接加算しないでください。
製品の要件が「現地時間で毎朝9時」であれば、UTCの単発イベントだけではその意味を表現できません。タイムゾーン識別子、タイムゾーン定義、繰り返しルールを別途設計する必要があり、読み込み先のクライアントに推測させることはできません。
読み込み結果で意味を確認する
スクリプトを実行したら、生成されたファイルをローカルに転送し、テスト専用のカレンダーに読み込みます。
xcrun swift calendar-check.swift
shasum -a 256 calendar-check.ics
転送後もハッシュ値を計算し、同じファイルをテストしていることを確認します。リモートデスクトップでファイルを表示できるだけでは、転送や内容の正しさは証明できません。
中国語の文字と絵文字が欠けていないか、カンマ・セミコロン・バックスラッシュが残っているか、説明がちょうど2行になっているか、時刻指定の予定が1時間になっているか、終日予定が一日だけを占めているかを、一項目ずつ確認します。プロジェクトが実際にサポートする各クライアントで、少なくとも一度は検証してください。あるクライアントが形式の不備を許容するからといって、その挙動を形式の基準にしてはいけません。
空のタイトル、非常に長い説明、月をまたぐ終日予定、同じUTCイベントを異なるタイムゾーンで表示するケースも追加します。時刻を目視で確認するときはクライアントのタイムゾーンも記録し、正しい時差換算を時刻のずれと誤判定しないようにします。
失敗を原因特定につながる証拠にする
CIでは、生成ファイル、入力サンプルの識別子、スクリプトのバージョン、実行時の終了コードを保存します。検査に失敗したら配布を止め、エラーを捕捉したまま古いファイルのアップロードを続けないでください。実際の予定のタイトルや説明には個人情報が含まれる可能性があるため、回帰テストの成果物には合成データだけを残します。
検証ゲートは3層に分けます。バイト層ではCRLFと折り返し解除後の一致を、プロパティ層では必須フィールドと日付型を、意味の層では読み込み後の日付・所要時間・本文を確認します。繰り返し予定や通知を追加するときは、どの層にどのルールを加えるのか、どの失敗サンプルを追加するのかを明確にします。
最後に、エクスポーターに対する最小の反例を用意します。カンマのエスケープを削除する、終日予定の終了日を開始日と同じ日にする、UTF-8の途中で切り詰める、といった例です。テストがこれらの不具合を検出できて初めて、書き出しの契約を守れていると言えます。現在の実装が動くことだけを確かめて終わりにしてはいけません。
よくある質問
ICSの行は75文字で折り返せばよいですか?
文字数ではなく75オクテットを目安にします。日本語や絵文字はUTF-8で複数バイトになり、継続行の先頭の空白も行の長さに含まれます。
終日予定のDTENDには最終日を指定しますか?
DTENDは予定に含まれない終了日です。1日の予定なら翌日を指定し、DTSTARTとDTENDの両方をVALUE=DATEにします。
次のビルドはクラウドのワークステーションで
Mac miniを1台まるごと専有できます。仮想マシンではありません。タスクに合わせて利用期間を選べます。実際の稼働状況はコントロールパネルでご確認ください。
クラウドMacをレンタル