昨天把併發的帳算清楚,後端到此定案。今天換到我最熟的那一端:iOS。AI 在這裡幫得上多少忙?它寫出來的東西,又要怎麼驗?
先講結論:型別交給契約產生,AI 只寫畫面;兩端一致,交給逐欄比對。真正的坑都長在「使用者的手機跟我的不一樣」的地方 —— 換成德文語系,AI 寫的金額變成 NT$9.999.99;字級放到最大,座位圖的字還是 9pt;那支 AI 寫的 UI 測試,對著一個完全空白的票券頁照樣通過。這些在我的電腦上全是綠的。
中間有一段沒寫進文章:web 版五個畫面(登入、活動列表、10×10 選位、倒數確認、票券)接上 API,上了 Cloudflare Pages。今天換第二個前端來吃同一份 API。
iOS 的三個畫面是寫作 session 的 AI 寫的(讀過規格的那一個):登入(refresh token 存 Keychain、遇到 401 自動 refresh 一次)、活動列表(唯讀)、我的票券(QR、離線時讀最後一次的快取)。它不用手寫任何一個型別、任何一個 HTTP 呼叫 —— 那些全是 swift-openapi-generator 從 openapi.yaml 產生的。契約改了,它的畫面 build 就紅。

但「同一份契約」在兩邊的意思不一樣,這要老實講。iOS 是真的從契約產出來的 —— Day 23 那個半形逗號,就是它紅出來的。web 是 vanilla JS,沒有從契約產出型別,只剩 API 側那支契約測試:它保證伺服器送出去的符合契約,web 拿到之後怎麼讀,目前沒有人驗。
所以「型別從契約產」這句話,在這個專案裡只有一半是真的。
用 XCUITest 在模擬器上接線上的 Worker 走一遍:登入 → 活動 → 票券,連跑兩次都過。

刻意不做的事,照非目標清單:不上架(沒有付費開發者帳號,只跑模擬器)、不做推播、不做離線編輯 —— 離線送出的報名,在你連上線的那一刻可能已經沒有位子了。
契約說金額一律是分(*_cents),分變成元的那一步,web 和 iOS 各做一次。iOS 這邊,AI 的第一版把千分位交給 NumberFormatter:
let f = NumberFormatter(); f.numberStyle = .decimal // ← 跟著裝置語系走
let y = f.string(from: NSNumber(value: yuan)) ?? String(yuan)
整數運算、沒有浮點數,金額檢查是綠的。我拿 web 與 iOS 共用的 14 組顯示向量,在同一台電腦上換語系跑兩次:
locale=zh_TW 14/14 相同
locale=de_DE 9/14 相同
✗ 999999: got NT$9.999.99 want NT$9,999.99
同一張 9,999.99 元的票,在德文語系的手機上會顯示成 NT$9.999.99 —— 而那份共用測試在我的電腦上是全綠的。當時是重讀程式才發現的,不是測試抓到的;修法是自己拆千分位、固定用逗號。
那是 AI 的通病,還是剛好那一次?我開了無菌室,兩組 prompt 各跑三次:A 把規則寫完整,B 只給一句「轉成像 NT$4,444.20 這樣的字串」。六次都沒用 NumberFormatter,回覆裡還主動說明「不跟手機的地區設定走」。B 三次都自己決定「一律顯示兩位小數」(NT$1,000.00)—— 跟 Day 25 一樣,規則沒寫的地方,它先替你選一個答案。
所以這個坑不是每次都踩,但踩的那一次,我的測試是綠的。 開發機的語系下測試抓不到,這一條就交給靜態檢查:check-money.sh 加一條,iOS 出現 NumberFormatter、.formatted(.number) 這類跟語系走的格式化一律紅燈。把 AI 那一版放回去:
Tickets/Money.swift:9: let f = NumberFormatter(); f.numberStyle = .decimal
❌ iOS 金額用了跟裝置語系走的格式化 —— 千分位固定逗號,在 Tickets/Money.swift 自己拆
下一次不管是誰、哪個模型把它寫回來,在任何一台電腦上都會紅。
語系只是其中一種「使用者的手機跟我的不一樣」。iOS 上還有字級、深色模式、螢幕大小 —— 我平常開發時,一樣也不會去切。
Xcode 15 起,XCUITest 有一個 performAccessibilityAudit():對當下的畫面跑一次無障礙稽核,回報對比不足、字級不會放大、文字被截斷這類問題,結果是一份清單。我讓 UI 測試走過五個畫面(登入、活動、座位、我的票券、票券明細),每個畫面跑一次稽核、截一張圖,在五種環境各跑一輪(後端用本機的 wrangler dev 和示範資料,不碰線上)。五種環境各報了 106–146 筆,大宗是座位圖。
一百個座位格的字寫死成 .font(.system(size: 9)) —— 字級放到最大,標題和價格都放大了好幾倍,座位編號還是 9pt:

但這份清單不能照單全收。登入按鈕的「對比不足」,是欄位還沒填時按鈕停用的灰字 —— 停用的控制項本來就不在對比要求內;還有一批問題沒指到任何元件,找不到是哪裡。反過來,有一個真問題它完全沒報:座位狀態只靠顏色分辨,VoiceOver 念到你自己的座位,只會念「C3」。
接著照 Day 25 的流程:讓它改、讓它問。我在 repo 外開了無菌室,只給畫面相關的 Swift 檔和這份稽核報告:
請直接修改
src/裡的檔案,把你判斷是真問題的修掉。稽核也會誤報。你覺得不是問題、或需要我決定的,不要改,列在回覆最後並說明理由。
8 輪、$0.45、兩分半,改了兩個檔:座位格換成會跟著放大的字級和格子大小,放不下就橫向捲動;有色底的格子一律用黑字;票券明細的長標題改成小標題,完整名稱放進內文。
| 環境 | 修之前 | 其中座位畫面 | AI 修一輪後 |
|---|---|---|---|
| iPhone 16 Pro・預設 | 131 | 110 | 24 |
| iPhone 16 Pro・最大無障礙字級 | 121 | 108 | 12 |
| iPhone 16 Pro・深色 | 134 | 114 | 20 |
| iPhone SE・預設 | 146 | 125 | 23 |
| iPhone SE・最大無障礙字級 | 106 | 93 | 10 |

更值得講的是它沒改的。停用按鈕、系統藍的「登出」、沒指到元件的那一批,它判成誤報或要我決定,每一條寫了理由 —— 跟我自己讀清單的判斷幾乎一樣。它還做了兩件稽核沒要求的事:自己幫座位加上給 VoiceOver 的狀態(「C3 我的」)和一排圖例;並指出「離線資料」的橘色小字對比只有約 2:1 —— 稽核碰不到,因為稽核時 App 不在離線狀態。
我回答它的問題後,它再改了一輪(圖例保留,離線和錯誤訊息改成對比夠的顏色 —— 稽核時不會出現,所以數字沒變);兩輪它都照實說沒辦法編譯,也沒重跑稽核。
但重跑稽核,還是抓到兩件事。
第一,它修的時候引進了新問題。 它自己加的那排圖例,四個標籤被報對比不足或差一點過 ——「空位」那格的淺灰色塊,在白底上幾乎看不見:

第二,它的修改撞上我們自己的檢查。 為了讓格子跟著字級放大,它寫了 @ScaledMetric private var cellWidth: CGFloat = 30 —— 而 check-money.sh 把 Swift 的 CGFloat 當成金額浮點,紅燈。CGFloat 是排版尺寸,金額不會用它;錯的是我們的檢查,抓太寬。拿掉 CGFloat(Double、Float、Decimal 照擋),自我測試補一個「不准誤報」的探針,這一筆記進帳本。
還有一件要照實講:第一次修的無菌室,我開在 repo 的暫時 worktree 底下,Claude Code 往上讀到了專案的 CLAUDE.md —— 那不算無菌室。上面的數字全部來自 repo 外重跑的乾淨版;被汙染的那次放在紀錄的附錄,結論方向一樣。
前面那張票券截圖,來自一支 AI 寫的 XCUITest:登入 → 活動 → 票券。我在本機又連跑了 10 次,10 次都過。
照這個系列的習慣,我故意弄壞一個地方:把票券頁的載入拿掉,讓它永遠不去打 GET /orders。畫面是全空的 —— 測試照樣通過。原因在最後幾行:
sleep(3)
snap(app, "3-tickets")
XCTAssertFalse(app.staticTexts["離線資料"].exists) // ← 空畫面也沒有「離線資料」

它只檢查「沒有出現離線資料」,沒有檢查「票券有出現」。改成等第一列真的出現,拿掉固定的 sleep:
XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "票券畫面是空的 —— GET /orders 沒回來")
弄壞的版本紅在這一行;正常的版本連跑 10 次都過,每次還從 18 秒降到 14 秒。一支從沒紅過的測試,你不知道它是在保護你,還是什麼都沒在看。
「兩端讀到的資料一樣」聽起來像是得靠人眼判定的事,而人眼判定正是這個系列一直在打的東西。
所以裁判換成這樣:同一個帳號、資料沒有變動時,對同一個 GET 拿到的 JSON 必須逐欄相同。 iOS 那一端不是抓網路封包,是讓產生出來的 client 把回應解碼、再編回 JSON —— 這樣比的才是「iOS 讀進去的東西」,不是「伺服器送出來的東西」。
BASE_URL=https://… ACCESS_TOKEN=… bash scripts/compare-clients.sh
✅ GET /events:2 筆,curl 與 iOS 逐欄相同
✅ GET /orders:1 筆,curl 與 iOS 逐欄相同

第一次跑其實紅了:promo_code: null 在 iOS 那一邊不見了。原因是 Swift 的 JSONEncoder 遇到 nil 就直接省略那個 key —— 解碼出來的值還是 nil,是再編碼的時候才消失的。所以比對時「null」和「沒有這個 key」視為相同,除此之外不做任何正規化。這一條規則寫在腳本的註解裡,因為一支會替你「正規化」的比對腳本,也可以把真的差異正規化掉。
web 沒有另外比,理由也寫在腳本裡:web/src/api.js 直接用 res.json(),沒有任何轉換,它拿到的就是 curl 拿到的。這是論證,不是實測 —— 我照實這樣標。web 畫面本身的實測留到 Day 29。
failed: 0 不代表全過AI 要自己 build、跑測試,在 iOS 這邊靠的是 Xcode 內建的 MCP。建置失敗時,它會照實回 isError: true;但正常那一次藏著另一個形狀:passed 0 / failed 0 / skipped 1 / total 1 —— 唯一的那支 UI 測試在 Xcode 裡拿不到示範帳號,自己跳過了。
failed: 0 在「測試全過」和「測試根本沒跑」兩種情況下長得一模一樣。讓 AI 透過 Xcode MCP 判斷綠燈,這條要寫進它的指示裡:看 passed == total − disabled,不要只看 failed == 0。 disabled 是測試清單裡標成停用的數量(這次是 0);跑起來才自己跳過的(skipped)不能扣掉,所以這一次是 0 ≠ 1,紅。
功能到此做完。剩下要蓋的是門:讓這一路擋下錯誤的東西永遠留著 —— 不是靠 AI 下次還會做對,也不是靠我記得。判準跟 Day 17 那張表一樣,有客觀對錯、誤報率低的才進門:
| 道 | 檢查 | 進 CI 門? |
|---|---|---|
| 0 | 靜態檢查的自我測試 | ✅ 34 個「必須抓到」、17 個「不准誤報」的探針 |
| 1 | 五條硬規則的靜態檢查(金額、時間、簽章、併發、價格快照) | ✅ 掃 API、web、iOS |
| 2 | schema 裁判的自我測試 + 測試(含契約、JWT、金額 fuzz) | ✅ 245 個 |
| 3 | 併發,重跑 5 次 | ✅ 單次通過不算 |
| — | 覆蓋率 | ❌ 它量的是「執行到」,不是判別力 |
| — | iOS 的 build 與測試 | ❌ CI 是 Linux runner,iOS 只在本機跑 |
第 0 道是這一路最貴的一課:檢查的第一版用了 rg,而 rg 在 sh 底下不存在 —— 三支檢查全部因為工具不存在而通過。所以 CI 的第一步,是每一支檢查先證明自己會紅、也不會亂紅。
截至 10/11,CI 跑了 45 次,只紅過一次,而那一次是逾時造成的誤報:三方競態那條測試在我的筆電上 2.3 秒跑完,GitHub 的 runner 跑了 5,010 毫秒,超過 vitest 預設的 5 秒逾時。程式是對的,門錯了。AI 那些被擋下的第一版,則從來沒進過 CI —— 它們在本機就被同一批檢查擋下了。CI 門守的,是哪天有人(或哪個模型)沒在本機跑就直接 push 的那一天。
iOS 這一端,AI 幫得上的忙很具體:型別從契約產生,它只寫畫面;稽核報告丟給它,一輪修掉八成,還分得出誤報、補上稽核看不到的問題。
但今天反覆出現的是同一件事:在我的電腦上是綠的。 AI 寫的金額在德文語系下錯了,共用測試在開發機上全綠;座位圖在最大字級下看不清,我平常的設定看不出來;UI 測試對著空畫面也會過;CI 唯一一次紅燈,在我的筆電上也跑不出來。測試跟著你的環境跑,你的環境不是使用者的環境。
所以裁判要換到使用者的環境去跑:換語系、放大字級、切深色、弄壞一個地方看測試會不會紅。AI 修完,用同一套裁判再跑一次 —— 它修得快,也會順手帶進新的問題。(這幾種環境下的畫面,下一步可以用快照測試存成基準圖,讓版面一變就紅;這個專案還沒做。)
這一篇留下的心法:
在你的電腦上全綠,不代表在使用者的手機上也對 —— 換語系、放大字級、切深色,讓機器去看你看不到的地方。AI 修得快、分得出誤報,但也會順手帶進新的問題;修完,用同一套裁判再跑一次。
明天:Android。同一份契約、同一組金額向量、同一套「換語系、放大字級、切深色」,換一個平台,AI 會不會踩同一個坑。
c7cfe38):github.com/n913239/event-signup-lab/tree/c7cfe38/ios/EventSignup/Sources/EventSignup
f66ef21):github.com/n913239/event-signup-lab/tree/f66ef21/devlog/raw/t055-2026-09-27
8a04092):github.com/n913239/event-signup-lab/tree/8a04092/devlog/raw/exp-07-ios-money
ea5c251):github.com/n913239/event-signup-lab/blob/ea5c251/scripts/check-money.sh
c7a1642):github.com/n913239/event-signup-lab/tree/c7a1642/devlog/raw/exp-08-ios-env
b374365):github.com/n913239/event-signup-lab/blob/b374365/ios/App/EventSignupAppUITests/LiveWalkthroughTests.swift
performAccessibilityAudit() 的介紹):developer.apple.com/videos/play/wwdc2023/10035
CGFloat 抓太寬、UI 測試假綠燈):github.com/n913239/event-signup-lab/blob/c7a1642/devlog/LEDGER.md
28786e5;目錄名與 README 的「Day 29」沿用排程調整前的篇號):github.com/n913239/event-signup-lab/tree/28786e5/devlog/raw/exp-day29-xcode-mcp