diff --git a/README.md b/README.md index 32d8349..b47344c 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。 詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。 -**目前**:`flutter analyze` 無 issue、`flutter test` **425 passed**(54 個測試檔,2026-08-01 實跑)。 -~~414 passed~~/~~383 passed~~/~~377 passed~~/~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 +**目前**:`flutter analyze` 無 issue、`flutter test` **434 passed**(55 個測試檔,2026-08-01 實跑)。 +~~425 passed~~/~~414 passed~~/~~383 passed~~/~~377 passed~~/~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 **2026-07-30 弱網逾時對帳的實跑收尾**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十四~十五輪): 先做了一支「請求照送、回應吃掉」的代理 [`tool/lossy_proxy.py`](tool/lossy_proxy.py)—— diff --git a/docs/TODO.md b/docs/TODO.md index 4ca1875..6ba0c98 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -77,6 +77,7 @@ - [🗓️ 預約司機+常用地點(跨端新功能,含 debug 兩個)](#-預約司機常用地點2026-07-31跨端新功能) - [📜 第二十四輪:「我的行程」只看得到最近 20 趟](#-2026-07-31-第二十四輪我的行程只看得到最近-20-趟本批修掉) - [🔁 第二十五輪:分頁尾巴會卡死在轉圈(回頭 debug 第二十四輪自己)](#-2026-08-01-第二十五輪分頁的尾巴會卡死在轉圈回頭-debug-第二十四輪自己本批修掉-2-個) +- [🔤 第二十六輪:App 的字數上限與後端數的不是同一種單位](#-2026-08-01-第二十六輪app-的字數上限與後端數的不是同一種單位本批修掉-6-個欄位) **四、維護、決策與待辦** - [🧹 清開發殘留 worktree/舊分支(維護項 5)](#-清開發殘留-worktree舊分支維護項-52026-07-28-完成) @@ -93,7 +94,7 @@ - **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。 **登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。 - **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。 -- 單元測試:**54 個測試檔、`flutter test` 425 passed**(2026-08-01 第二十四輪併回 main 並修完尾巴那兩個洞後實測; +- 單元測試:**55 個測試檔、`flutter test` 434 passed**(2026-08-01 第二十六輪後實測; `flutter analyze` 無 issue、`flutter build apk --debug --flavor customer` 成功)。 ~~(54 項)~~ 是 2026-07-10 的數字,長期沒更新,已更正——**本節的數字請跟著最後盤點日一起改**。 - 遠端:`github.com/thothawei/fleet-app`。**2026-07-29 實查**:`git ls-remote --heads origin` 只有 `main`、 @@ -2461,9 +2462,93 @@ PSQL_DSN='postgres://fleet:change_me@127.0.0.1:5433/fleet?sslmode=disable' \ --- +## 🔤 2026-08-01 第二十六輪:App 的字數上限與後端數的不是同一種單位(本批修掉 6 個欄位) + +> 本輪掃的族是**跨端輸入邊界對帳**——App 的欄位上限 vs 後端 validator。 +> 從沒掃過,而且掃出來的東西比預期硬:不只有三個欄位沒擋, +> **有擋的那三個擋的單位也是錯的**。 + +### 根因一句話 + +Flutter 的 `TextField.maxLength` 數的是 **grapheme cluster**(使用者感知的「一個字」), +後端每一支上限數的都是 **rune**(`utf8.RuneCountInString` / `len([]rune(...))`)—— +對 ASCII 與中文相同,對 emoji 差到 7 倍。 + +### 取證(不是查文件,是實跑探針) + +``` +「👨‍👩‍👧‍👦」characters=1 runes=7 「🇹🇼」characters=1 runes=2 「👍🏽」characters=1 runes=2 +TextField(maxLength: 5) 擋完後 → characters=5 runes=35 +``` + +`maxLength: 5` 放行了 **35 個 rune**。於是評分畫面的計數器顯示「200/200 沒超過」, +送出卻被後端以 400 擋下,而錯誤訊息(「評論長度超過上限」)**不會說上限是多少**—— +對照著計數器看只會更困惑。 + +### 六個欄位的對帳表 + +| 欄位 | 後端上限(rune) | 修改前的 App | 判定 | +|---|---|---|---| +| 評分評論 | `ratingCommentMaxRunes` 200 | `maxLength: 200`(數 cluster) | ❌ 單位錯 | +| 遺失物描述 | `lostItemDescMaxRunes` 300 | `maxLength: 300`(數 cluster) | ❌ 單位錯 | +| 預約備註 | `maxScheduleNoteRunes` 200 | `maxLength: 200`(數 cluster) | ❌ 單位錯 | +| 聊天訊息 | `chatMaxRunes` 500 | **完全沒有上限** | ❌ 只能送出後才知道 | +| 常用地點名稱 | `maxPlaceLabelRunes` 40 | **完全沒有上限** | ❌ 同上 | +| 常用地點地址 | `maxPlaceAddressRunes` 200 | 唯讀(地圖選點反查) | ⚠️ 見下 | + +### 修法 + +- 新增共用的 [`RuneLimitingTextInputFormatter`](../lib/shared/widgets/rune_limit.dart) + + `runeCounter()`:**擋的單位與顯示的數字都與後端一致**。 +- **裁切落在 cluster 邊界**,不是直接砍 rune 陣列——從 ZWJ 序列中間切下去會留下 + 半個字(孤立的修飾符/ZWJ 尾巴),畫面會出現使用者沒打過的東西。寧可少收一個完整的字。 +- 保留 `maxLength` 只為了讓 Flutter 畫出計數器的位置,實際強制交給 formatter + (`maxLengthEnforcement: none`)——兩套同時生效會先被 cluster 那套擋掉。 +- 聊天輸入框**不放計數器**:訊息框下面多一行 `x/500` 太吵,而 500 字平常打不到。 +- **常用地點「地址」刻意不加限制器**:它是唯讀的、由地圖選點反查填入。 + 真的超過 200 rune 時應該讓後端拒絕,而不是在客戶端把地址默默截短—— + **截短過的地址是錯的地址**,司機會開到別的地方去。 + +### 驗收 + +- `flutter analyze` 無 issue、`flutter test` **434 passed**(425 +新 9)。 +- **反向驗證兩個半邊各一次**: + - 拿掉聊天輸入框的 formatter(回到先前無上限)→ 700 個 rune 整段進得去,該案 FAIL。 + - 把限制器改回數 cluster(=`maxLength` 的行為)→ **5 案 FAIL**。 +- **有一個案子專門驗「真畫面接上去了」**(常用地點名稱:開編輯表單→輸入 30 面旗子 + →只留 20 面、計數器顯示 `40/40`)——只驗共用元件本身的話, + 某個畫面漏接 `inputFormatters` 不會有任何測試變紅。 +- `flutter build apk --debug --flavor customer` 成功。 + +### 同族還沒碰的角落 + +- **遺失物描述/預約備註/評分評論這三個畫面沒有各自的「真畫面接上去了」測試** + (只有常用地點與聊天室有)。三個接法完全相同、`analyze` 也綠, + 但嚴格說它們的接線目前靠 review 不靠測試。**條件**:下次動到這三個畫面時順手補。 +- **司機端沒有任何自由輸入欄位**(車牌走後端驗證、聊天共用同一個元件),所以本輪不必動司機端。 + +--- + ## 下次任務 -> **🎯 2026-08-01 第二十五輪之後的狀態(開工先看這段)** +> **🎯 2026-08-01 第二十六輪之後的狀態(開工先看這段)** +> +> 1. **`gh pr list`(三個 repo)照樣是第一件事**——本輪開工時三個 repo 的 open PR 皆為 0。 +> 2. **第九次實查三個外部卡點:依然都不在**(`android/app/google-services.json`、 +> `ios/Runner/GoogleService-Info.plist` 不存在、`xcrun devicectl list devices` → No devices found)。 +> 3. **這一輪學到的一條(值得單獨記住)**: +> **「兩邊都有做限制」不等於「限制得住」——要先問兩邊數的是不是同一種單位。** +> 評分/協尋/預約那三個欄位的 `maxLength` 數字與後端**完全相同**(200/300/200), +> 程式碼上看起來對得整整齊齊,實際上一個數 grapheme、一個數 rune。 +> 對帳跨端契約時,**單位與數值要分開問兩次**。 +> 4. **下一輪候選**(依價值排序): +> - **同族收尾**:遺失物描述/預約備註/評分評論三個畫面補「真畫面接上去了」的測試 +> (本輪只補了常用地點與聊天室)。**條件**:下次動到那三個畫面時順手做,不必為它單開一輪。 +> - **數值與金額格式**(從沒掃過):`formatCentsAsNtd` 的 0/負數/大數、 +> 車資與清潔費相加的邊界、收入頁的月聚合。與本輪同一類(跨端表示法對帳)。 +> - **狀態機**(從沒掃過):後端新增 ride status code 時 App 的退路 +> (目前 `statusLabel` 有 default,但「未知狀態要不要給操作鈕」沒有統一答案)。 + > > 1. **`gh pr list`(三個 repo)照樣是第一件事。** 本輪開工時:app 只有 #97(就是本輪在收的那支)、 > dispatch #72(redis 讓開埠號)、admin 0。 diff --git a/lib/customer/screens/lost_item_screen.dart b/lib/customer/screens/lost_item_screen.dart index 79a23b4..0883861 100644 --- a/lib/customer/screens/lost_item_screen.dart +++ b/lib/customer/screens/lost_item_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/api/fleet_api_client.dart' show ApiException; import '../../core/models/models.dart'; import '../../core/util/money.dart'; import '../../shared/screens/ride_chat_screen.dart'; +import '../../shared/widgets/rune_limit.dart'; import '../customer_controller.dart'; /// 乘客端遺失物協尋:對已完成行程回報遺失 → 顯示處理費 → 與司機即時對話 → @@ -222,7 +224,12 @@ class _ReportForm extends StatelessWidget { TextField( controller: description, maxLines: 3, + // 與後端 lostItemDescMaxRunes(300)對齊,且數的是 rune 不是 grapheme + // (見 RuneLimitingTextInputFormatter 的說明)。 + inputFormatters: const [RuneLimitingTextInputFormatter(300)], maxLength: 300, + maxLengthEnforcement: MaxLengthEnforcement.none, + buildCounter: runeCounter(300, description), decoration: const InputDecoration( labelText: '物品描述(例:黑色錢包掉在後座)', border: OutlineInputBorder(), diff --git a/lib/customer/screens/saved_places_screen.dart b/lib/customer/screens/saved_places_screen.dart index 700fa22..a35f8e3 100644 --- a/lib/customer/screens/saved_places_screen.dart +++ b/lib/customer/screens/saved_places_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../../core/models/models.dart'; +import '../../shared/widgets/rune_limit.dart'; import '../customer_controller.dart'; import '../widgets/saved_places_bar.dart' show savedPlaceIcon; import 'map_picker_screen.dart'; @@ -335,6 +337,12 @@ class _PlaceEditorSheetState extends State<_PlaceEditorSheet> { TextField( controller: _label, onChanged: (_) => setState(() {}), + // 先前**完全沒有上限**:後端 maxPlaceLabelRunes 是 40,超過會回 + // 「地點名稱過長」,但那句話沒說上限是多少,使用者只能亂猜著砍。 + inputFormatters: const [RuneLimitingTextInputFormatter(40)], + maxLength: 40, + maxLengthEnforcement: MaxLengthEnforcement.none, + buildCounter: runeCounter(40, _label), decoration: const InputDecoration( labelText: '名稱', hintText: '例如:健身房、媽媽家', diff --git a/lib/customer/screens/scheduled_rides_screen.dart b/lib/customer/screens/scheduled_rides_screen.dart index 6a116f2..679a301 100644 --- a/lib/customer/screens/scheduled_rides_screen.dart +++ b/lib/customer/screens/scheduled_rides_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../../core/models/models.dart'; +import '../../shared/widgets/rune_limit.dart'; import '../customer_controller.dart'; import '../widgets/saved_places_bar.dart'; import 'map_picker_screen.dart'; @@ -428,7 +430,12 @@ class _ScheduleEditorScreenState extends State<_ScheduleEditorScreen> { const SizedBox(height: 20), TextField( controller: _note, + // 與後端 maxScheduleNoteRunes(200)對齊,且數的是 rune 不是 grapheme + // (見 RuneLimitingTextInputFormatter 的說明)。 + inputFormatters: const [RuneLimitingTextInputFormatter(200)], maxLength: 200, + maxLengthEnforcement: MaxLengthEnforcement.none, + buildCounter: runeCounter(200, _note), decoration: const InputDecoration( labelText: '給司機的備註(選填)', hintText: '例如:有一件大行李', diff --git a/lib/customer/widgets/rating_sheet.dart b/lib/customer/widgets/rating_sheet.dart index a731e3d..3cb6c34 100644 --- a/lib/customer/widgets/rating_sheet.dart +++ b/lib/customer/widgets/rating_sheet.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../shared/widgets/rune_limit.dart'; import '../customer_controller.dart'; @@ -109,7 +112,12 @@ class _RatingSheetState extends State<_RatingSheet> { controller: _commentCtrl, enabled: !_submitting, maxLines: 3, - maxLength: 200, // 與後端 ratingCommentMaxRunes 對齊 + // 與後端 ratingCommentMaxRunes(200)對齊。**用 rune 不用 maxLength**: + // maxLength 數的是 grapheme cluster,emoji 會讓計數器說沒超過而後端擋下。 + inputFormatters: const [RuneLimitingTextInputFormatter(200)], + maxLength: 200, + maxLengthEnforcement: MaxLengthEnforcement.none, + buildCounter: runeCounter(200, _commentCtrl), decoration: const InputDecoration( labelText: '想說的話(選填)', border: OutlineInputBorder(), diff --git a/lib/shared/screens/ride_chat_screen.dart b/lib/shared/screens/ride_chat_screen.dart index 9103365..7aa6988 100644 --- a/lib/shared/screens/ride_chat_screen.dart +++ b/lib/shared/screens/ride_chat_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../core/api/fleet_api_client.dart' show ApiException; import '../../core/models/models.dart'; import '../widgets/app_lifecycle_reactor.dart'; +import '../widgets/rune_limit.dart'; /// 乘客↔司機共用聊天室。 /// - 歷史:進場以 REST 載入,之後以 afterId 增量補讀(WS 斷線重連保底)。 @@ -264,6 +265,14 @@ class _RideChatScreenState extends State { controller: _input, minLines: 1, maxLines: 4, + // 先前**完全沒有上限**:後端 chatMaxRunes 是 500,超過回 400 + // 「訊息長度超過上限」——訊息會留在輸入框(送出路徑本來就這樣設計), + // 但那句話沒說上限是多少,只能一段一段刪著試。 + // 這裡不放計數器:聊天輸入框下面多一行 `x/500` 太吵, + // 而 500 字對一則訊息來說平常打不到。 + inputFormatters: const [ + RuneLimitingTextInputFormatter(500), + ], textInputAction: TextInputAction.send, onSubmitted: (_) => _send(), decoration: const InputDecoration( diff --git a/lib/shared/widgets/rune_limit.dart b/lib/shared/widgets/rune_limit.dart new file mode 100644 index 0000000..ed9c756 --- /dev/null +++ b/lib/shared/widgets/rune_limit.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 依 **rune 數**(Unicode code point)限制輸入長度,並提供同一單位的計數器。 +/// +/// **為什麼不直接用 `TextField.maxLength`**:它數的是 grapheme cluster +/// (使用者感知的「一個字」),而後端每一支上限數的都是 rune +/// (`utf8.RuneCountInString` / `len([]rune(...))`)。兩者對 ASCII 與中文相同, +/// 對 emoji 差很多——實測 `maxLength: 5` 會放行 5 個 ZWJ 家庭 emoji = **35 個 rune**: +/// +/// ``` +/// 「👨‍👩‍👧‍👦」characters=1 runes=7 「🇹🇼」characters=1 runes=2 「👍🏽」characters=1 runes=2 +/// ``` +/// +/// 於是畫面上的計數器顯示「200/200 沒超過」,送出卻被後端以 400 擋下, +/// 而錯誤訊息(「評論長度超過上限」)**不會告訴使用者到底該砍掉多少**—— +/// 對照計數器只會更困惑。擋在同一個單位上,這條路才不會出現。 +class RuneLimitingTextInputFormatter extends TextInputFormatter { + const RuneLimitingTextInputFormatter(this.maxRunes) + : assert(maxRunes > 0, 'maxRunes 必須為正'); + + final int maxRunes; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (runeCount(newValue.text) <= maxRunes) return newValue; + // **以 grapheme cluster 為單位裁切**,不是直接砍 rune 陣列: + // 從 ZWJ 序列中間切下去會留下半個字(孤立的修飾符/ZWJ 尾巴), + // 畫面會出現使用者沒打過的東西。寧可少收一個完整的字。 + final buffer = StringBuffer(); + var used = 0; + for (final cluster in newValue.text.characters) { + final n = cluster.runes.length; + if (used + n > maxRunes) break; + buffer.write(cluster); + used += n; + } + final text = buffer.toString(); + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: TextRange.empty, + ); + } +} + +/// 與後端同一單位的長度計算。 +int runeCount(String s) => s.runes.length; + +/// `TextField.buildCounter` 用的計數器,顯示的數字與後端檢查的是同一個。 +/// +/// 沿用 `maxLength` 的 `x/y` 形狀(使用者已經認得),只是把 x 換成 rune 數。 +/// **要傳 controller**:counter 不是 `EditableText` 的後代,用 context 往上找拿不到 +/// 目前文字,只能退回 framework 給的 `currentLength`——那正是 grapheme 數。 +/// `TextField` 會跟著 controller 變動重建,所以這裡讀到的一定是最新值。 +InputCounterWidgetBuilder runeCounter( + int maxRunes, + TextEditingController controller, +) { + return ( + BuildContext context, { + required int currentLength, + required int? maxLength, + required bool isFocused, + }) { + final used = runeCount(controller.text); + return Text( + '$used/$maxRunes', + style: Theme.of(context).textTheme.bodySmall, + semanticsLabel: '已輸入 $used 字,上限 $maxRunes 字', + ); + }; +} diff --git a/test/rune_limit_test.dart b/test/rune_limit_test.dart new file mode 100644 index 0000000..5a5f5e8 --- /dev/null +++ b/test/rune_limit_test.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:line_fleet_app/core/api/customer_api_client.dart'; +import 'package:line_fleet_app/core/models/models.dart'; +import 'package:line_fleet_app/customer/customer_controller.dart'; +import 'package:line_fleet_app/customer/screens/saved_places_screen.dart'; +import 'package:line_fleet_app/shared/screens/ride_chat_screen.dart'; +import 'package:line_fleet_app/shared/widgets/rune_limit.dart'; +import 'package:line_fleet_app/core/theme/app_theme.dart'; +import 'package:provider/provider.dart'; + +/// ZWJ 家庭 emoji:1 個 grapheme cluster、**7 個 rune**。 +/// 後端的每一支上限數的都是 rune,Flutter 的 `maxLength` 數的是 cluster—— +/// 這一整份測試釘的就是這個落差。 +const _family = '👨‍👩‍👧‍👦'; +const _flag = '🇹🇼'; // 1 cluster / 2 runes + +TextEditingValue _v(String text) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), +); + +void main() { + group('RuneLimitingTextInputFormatter', () { + test('前提:cluster 數與 rune 數真的不同(不同就沒有這個 bug)', () { + expect(_family.characters.length, 1); + expect(_family.runes.length, 7); + expect(_flag.characters.length, 1); + expect(_flag.runes.length, 2); + // ASCII 與中文兩者相同——所以這個坑平常不會被踩到,只在 emoji 上炸。 + expect('你好abc'.characters.length, '你好abc'.runes.length); + }); + + test('ASCII/中文:與一般長度限制行為相同', () { + const f = RuneLimitingTextInputFormatter(5); + expect(f.formatEditUpdate(_v(''), _v('你好嗎')).text, '你好嗎'); + expect(f.formatEditUpdate(_v(''), _v('12345')).text, '12345'); + expect(f.formatEditUpdate(_v(''), _v('123456')).text, '12345'); + }); + + test('emoji:擋在 rune 上,不是擋在 cluster 上', () { + const f = RuneLimitingTextInputFormatter(20); + // 6 個家庭 emoji = 42 runes。Flutter 的 maxLength: 20 會全部放行(6 clusters)。 + final out = f.formatEditUpdate(_v(''), _v(_family * 6)).text; + expect(out.runes.length, lessThanOrEqualTo(20)); + // 20 runes 裝得下 2 個家庭(14),第 3 個會超過 → 只留 2 個。 + expect(out, _family * 2); + }); + + test('裁切落在 cluster 邊界,不會切出半個 emoji', () { + const f = RuneLimitingTextInputFormatter(10); + final out = f.formatEditUpdate(_v(''), _v(_family * 3)).text; + // 10 runes 只裝得下 1 個家庭(7);剩下 3 個 rune 不足以再放一個, + // **不可以**切一半留下孤立的修飾符/ZWJ 尾巴。 + expect(out, _family); + expect(out.characters.length, 1, reason: '輸出必須是完整的 cluster'); + }); + + test('沒超過就原樣放行(不動 selection/composing)', () { + const f = RuneLimitingTextInputFormatter(50); + const value = TextEditingValue( + text: '打到一半', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 0, end: 4), + ); + expect(f.formatEditUpdate(_v(''), value), value); + }); + + test('runeCount 與後端同一單位', () { + expect(runeCount(_family), 7); + expect(runeCount('你好'), 2); + expect(runeCount(''), 0); + }); + }); + + group('真實畫面', () { + testWidgets('聊天輸入框:超過 500 rune 的 emoji 貼不進去(先前完全沒有上限)', (tester) async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + await tester.pumpWidget( + MaterialApp( + theme: appLightTheme, + home: RideChatScreen( + rideId: 1, + selfRole: 'customer', + title: '聯絡司機', + loadHistory: (rideId, {int afterId = 0}) async => const [], + send: (rideId, body, {String? clientMsgId}) async => RideMessage( + id: 1, + rideId: rideId, + senderRole: 'customer', + senderId: 1, + body: body, + ), + incoming: controller.stream, + onVisibilityChanged: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + // 100 個家庭 emoji = 700 runes。cluster 數只有 100,所以 maxLength 那套 + // 會整段放行,然後被後端以 400「訊息長度超過上限」擋下。 + await tester.enterText(find.byType(TextField), _family * 100); + await tester.pump(); + + final text = tester + .widget(find.byType(TextField)) + .controller! + .text; + expect( + text.runes.length, + lessThanOrEqualTo(500), + reason: '要擋在送出之前,不是讓後端回 400', + ); + expect(text.characters.length, 71, reason: '500 ÷ 7 = 71 個完整的家庭 emoji'); + }); + + testWidgets('計數器數的是 rune:一個家庭 emoji 顯示 7/200 而不是 1/200', (tester) async { + final ctrl = TextEditingController(); + addTearDown(ctrl.dispose); + // 直接驗 runeCounter 這支 builder 產出的文字——它就是評分/協尋/預約 + // 三個畫面共用的那一支。 + await tester.pumpWidget( + MaterialApp( + theme: appLightTheme, + home: Scaffold( + body: TextField( + controller: ctrl, + inputFormatters: const [RuneLimitingTextInputFormatter(200)], + maxLength: 200, + maxLengthEnforcement: MaxLengthEnforcement.none, + buildCounter: runeCounter(200, ctrl), + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), _family); + await tester.pump(); + expect( + find.text('7/200'), + findsOneWidget, + reason: '顯示 1/200 就等於畫面說沒超過、後端卻擋下', + ); + expect(find.text('1/200'), findsNothing); + }); + + testWidgets('常用地點「名稱」:真的接上去了,而且擋在 40 rune(先前完全沒有上限)', (tester) async { + final ctrl = CustomerController(api: _EmptyPlacesApi()); + addTearDown(ctrl.dispose); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + await tester.pumpWidget( + MaterialApp( + theme: appLightTheme, + home: ChangeNotifierProvider.value( + value: ctrl, + child: const SavedPlacesScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + // 開新增地點的編輯表單——這一步是為了驗**真畫面有沒有接上**, + // 不是只驗共用元件本身(共用元件在上面已經單獨驗過了)。 + await tester.tap(find.widgetWithText(FloatingActionButton, '新增地點')); + await tester.pumpAndSettle(); + + final label = find.widgetWithText(TextField, '名稱'); + expect(label, findsOneWidget, reason: '前置條件:編輯表單有打開'); + await tester.enterText(label, _flag * 30); // 30 clusters = 60 runes + await tester.pump(); + + final text = tester.widget(label).controller!.text; + expect( + text.runes.length, + lessThanOrEqualTo(40), + reason: '後端 maxPlaceLabelRunes 是 40,要擋在送出之前', + ); + expect(text.characters.length, 20, reason: '40 ÷ 2 = 20 面完整的旗子'); + expect(find.text('40/40'), findsOneWidget, reason: '計數器要說出 rune 數'); + }); + }); +} + +/// 只回空清單的常用地點 API(本檔只驗輸入限制,不驗清單內容)。 +class _EmptyPlacesApi extends CustomerApiClient { + _EmptyPlacesApi() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + @override + Future> fetchSavedPlaces() async => const []; +}