From 628ace3eb1ea5978f6a5eb09dbc61236dad24b84 Mon Sep 17 00:00:00 2001 From: awei Date: Sat, 1 Aug 2026 20:26:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20WS=20=E9=87=8D=E9=80=A3=E5=BE=8C?= =?UTF-8?q?=E6=B2=92=E6=9C=89=E4=BA=BA=E5=B0=8D=E5=B8=B3=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8F=B8=E6=A9=9F=E7=B9=BC=E7=BA=8C=E9=96=8B=E5=BE=80=E5=B7=B2?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E7=9A=84=E8=A1=8C=E7=A8=8B=EF=BC=88=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E5=8D=81=E4=B9=9D=E8=BC=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一輪列的兩個候選查下去是同一個洞的兩半,而且**我上一輪寫的一句話是錯的**: 第二十八輪的「同族還沒碰的角落」寫著「司機端待命時有輪詢」。實查是錯的—— grep Timer 只有 _locationHealthTimer(定位健康度),司機端**沒有任何行程輪詢**。 已在 TODO 當場更正。 根因:onConnectionChanged 只記旗標、notifyListeners,沒有人去問後端;而 WS 重連 不補送斷線期間的事件(第四輪就記過)。兩件事湊起來,斷線視窗裡發生的每一件事 都永遠送不到。 司機端最嚴重:乘客端至少在有進行中訂單時每 15 秒輪詢會校正,司機端只剩 onAppResumed——而那需要 App 真的進過背景,開車時 App 全程在前景,走不到。 最惡劣的具體後果:斷線視窗裡乘客取消 → ride.cancelled 送不到 → 司機繼續開往 一個已經取消的上車點,而畫面上那張行程卡看起來完全正常。 修法兩端同一招:onConnectionChanged 在**重連**時補一次對帳(司機 _restoreActiveRide、乘客 refreshActive,都走 silent)。只在重連時做、第一次連上 不做(新增 _everConnected 旗標;冷啟動時 init 才剛問過);斷線本身不觸發。 驗收:flutter analyze 無 issue、flutter test 448 passed(444+新 4)、58 個測試檔、 flutter build apk --debug --flavor customer 成功。司機端主案在修改前就是紅的。 反向驗證三處:拿掉司機端對帳 → 主案 FAIL;拿掉 _everConnected 守衛 → 「第一次 連上不重複對帳」FAIL;拿掉乘客端對帳 → 乘客端那案 FAIL。 過程中修正一個測試錯誤:第一版司機端測試跳過了「第一次連上」,_everConnected 永遠 false,測試紅得像是修法沒生效——錯的是測試不是程式,production 一定會先 連上一次。與上一輪同一個教訓:測試紅了要先問「是我的假設錯了嗎」。 Co-Authored-By: Claude Opus 5 --- README.md | 4 +- docs/TODO.md | 101 ++++++++++++++++-- lib/customer/customer_controller.dart | 12 +++ lib/driver/driver_controller.dart | 125 ++++++++++++++--------- test/customer_multi_device_test.dart | 77 ++++++++++++++ test/driver_ws_reconnect_test.dart | 141 ++++++++++++++++++++++++++ 6 files changed, 400 insertions(+), 60 deletions(-) create mode 100644 test/driver_ws_reconnect_test.dart diff --git a/README.md b/README.md index 67506e9..790b3ca 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。 詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。 -**目前**:`flutter analyze` 無 issue、`flutter test` **444 passed**(57 個測試檔,2026-08-01 實跑)。 -~~440 passed~~/~~434 passed~~/~~425 passed~~/~~414 passed~~/~~383 passed~~/~~377 passed~~/~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 +**目前**:`flutter analyze` 無 issue、`flutter test` **448 passed**(58 個測試檔,2026-08-01 實跑)。 +~~444 passed~~/~~440 passed~~/~~434 passed~~/~~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 f2e8b8e..32d93eb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -80,6 +80,7 @@ - [🔤 第二十六輪:App 的字數上限與後端數的不是同一種單位](#-2026-08-01-第二十六輪app-的字數上限與後端數的不是同一種單位本批修掉-6-個欄位) - [🚦 第二十七輪:後端比 App 新時,進行中的訂單會從畫面上消失](#-2026-08-01-第二十七輪後端比-app-新時進行中的訂單會從畫面上消失本批修掉-1-個補完上輪缺口) - [📱 第二十八輪:同一帳號的第二台裝置永遠不知道有進行中的行程](#-2026-08-01-第二十八輪同一帳號的第二台裝置永遠不知道有進行中的行程本批修掉-1-個) +- [🔌 第二十九輪:WS 重連後沒有人對帳——司機繼續開往已取消的行程](#-2026-08-01-第二十九輪ws-重連後沒有人對帳司機繼續開往已取消的行程本批修掉-2-個) **四、維護、決策與待辦** - [🧹 清開發殘留 worktree/舊分支(維護項 5)](#-清開發殘留-worktree舊分支維護項-52026-07-28-完成) @@ -96,7 +97,7 @@ - **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。 **登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。 - **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。 -- 單元測試:**57 個測試檔、`flutter test` 444 passed**(2026-08-01 第二十八輪後實測; +- 單元測試:**58 個測試檔、`flutter test` 448 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`、 @@ -2655,18 +2656,102 @@ WS 那條的 `default: break;` 是忽略未知事件型別,這是對的(事 ### 同族還沒碰的角落 -- **WS 斷線期間發生的事**:B 台若在事件送出的當下沒有連線,這次對帳就不會發生。 - 目前靠 `onAppResumed` 的無條件 `refreshActive` 自癒——**但那需要 App 進過背景再回來**, - 一台一直開在前景的裝置不會走到。**條件**:等真的有人回報再處理; - 要修的話是「WS 重連成功後補一次對帳」,動的是 `onConnectionChanged`。 -- **司機端沒有這條對帳**:司機的 `_handleWsEvent` 對不認得的單也是丟掉, - 但司機端在待命時**有**輪詢(hero 上線後持續回報位置並對帳),情境不同。**未查證,下輪可看。** +- ~~**WS 斷線期間發生的事**~~ ✅ **第二十九輪修掉**(見下一段)。 +- ~~**司機端沒有這條對帳**~~ ✅ **第二十九輪查證了,而且比預期嚴重**—— + **上面那句「司機端待命時有輪詢」是錯的,已更正**:司機端**完全沒有行程輪詢**。 + +--- + +## 🔌 2026-08-01 第二十九輪:WS 重連後沒有人對帳——司機繼續開往已取消的行程(本批修掉 2 個) + +> 上一輪列了兩個候選,本輪查下去發現**它們是同一個洞的兩半**,而且 +> **我上一輪寫的那句「司機端待命時有輪詢」是錯的**(下面已更正)。 + +### 先更正一個錯誤敘述 + +第二十八輪的「同族還沒碰的角落」寫著「司機端在待命時**有**輪詢」。 +**實查是錯的**:`grep Timer lib/driver/driver_controller.dart` 只有 +`_locationHealthTimer`(定位健康度重評),**沒有任何行程輪詢**。 +司機端的 `_api.activeRide()` 只在三種時機被呼叫:`init()`、自己動作之後 +(接單/上車/完成)、`onAppResumed`。 + +### 根因一句話 + +`onConnectionChanged` **只記旗標、notifyListeners**,沒有人去問後端; +而 WS 重連**不補送**斷線期間的事件(第四輪就記過)。 +兩件事湊起來,斷線視窗裡發生的每一件事都永遠送不到。 + +### 為什麼司機端最嚴重 + +| 端 | 斷線視窗的補救 | 結果 | +|---|---|---| +| 乘客端 | 有進行中訂單時每 15 秒輪詢 | 有訂單時會自己校正;**閒置時輪詢是停著的**(第二十八輪那個情境剛好落在這裡) | +| **司機端** | **沒有任何行程輪詢** | 只剩 `onAppResumed`——而那需要 App **真的進過背景**。開車時 App 全程在前景,**走不到** | + +最惡劣的具體後果:斷線視窗裡乘客取消 → `ride.cancelled` 送不到 → +**司機繼續開往一個已經取消的上車點**,而畫面上那張行程卡看起來完全正常。 + +### 修法(兩端同一招) + +`onConnectionChanged` 在**重連**時補一次對帳:司機端 `_restoreActiveRide(silent: true)`、 +乘客端 `refreshActive(silent: true)`。 + +- **只在「重連」時做,第一次連上不做**(新增 `_everConnected` 旗標): + 冷啟動時 `init()` 才剛問過,再問只是多打一支 API。 +- **斷線本身不觸發**(問了也問不到)。 +- 兩端都走 `silent`:這不是使用者按出來的,失敗不該冒錯誤橫幅。 + +### 驗收 + +- `flutter analyze` 無 issue、`flutter test` **448 passed**(444 +新 4)、58 個測試檔。 +- **先紅再修**:司機端主案在修改前就是紅的(`activeCalls` 停在 1)。 +- **反向驗證三處**: + - 拿掉司機端的重連對帳 → 主案 FAIL; + - 拿掉 `_everConnected` 守衛 → 「第一次連上不重複對帳」FAIL(變成 2 次); + - 拿掉乘客端的重連對帳 → 乘客端那案 FAIL。 +- `flutter build apk --debug --flavor customer` 成功。 + +### 過程中修正的一個測試錯誤 + +第一版司機端測試**跳過了「第一次連上」**,於是 `_everConnected` 永遠是 false、 +重連判定不成立,測試紅得像是修法沒生效。**錯的是測試不是程式**—— +production 一定會先連上一次。已補上 `ws.setConnected(true)` 當作初次連線。 +這與上一輪那條是同一個教訓:**測試紅了要先問「是我的假設錯了嗎」**。 + +### 同族還沒碰的角落 + +- **`onConnectionChanged` 收不到「連線其實已經死了」的情況**(弱網:連得上但通不了, + 凍結的後端不會送 FIN)。那一族在第六輪處理過一半(司機端有 `locationStale` 偵測), + 但**重連對帳幫不上忙——因為根本沒有「重連」事件**。 + **條件**:要修得靠心跳逾時主動斷線重連,動的是 `FleetWsClient`。 +- **乘客端閒置時仍然沒有輪詢**:本輪讓「重連」與「收到不認得的事件」兩條路補上了, + 但「WS 一直連著、事件也真的沒送到」這種情況還是沒人管。 + **條件**:那要嘛信任後端一定會送、要嘛開閒置輪詢(每台裝置每 15 秒一支 API)—— + 現在的證據不足以支持後者的成本。 --- ## 下次任務 -> **🎯 2026-08-01 第二十八輪之後的狀態(開工先看這段)** +> **🎯 2026-08-01 第二十九輪之後的狀態(開工先看這段)** +> +> 1. **`gh pr list`(三個 repo)照樣是第一件事**——本輪開工時皆為 0。 +> 2. **第十二次實查三個外部卡點:依然都不在**。 +> 3. **這一輪學到的一條(最重要)**: +> **上一輪寫進「還沒碰的角落」的敘述,下一輪要當成假設去查,不能當成事實。** +> 第二十八輪我寫「司機端待命時有輪詢,情境不同」——**那句是錯的**, +> 司機端根本沒有行程輪詢,而且因此是兩端裡最嚴重的一邊。 +> 寫「未查證」三個字有用,但**只有真的去查才算數**。 +> 4. **下一輪候選**(依價值排序): +> - **弱網下沒有「重連」事件**:凍結的後端不會送 FIN,`onConnectionChanged` +> 永遠不會 false→true,本輪的對帳幫不上忙。要修得靠心跳逾時主動斷線重連, +> 動的是 `FleetWsClient`。**這是本輪對帳唯一補不到的缺口,最具體。** +> - **App 版本比後端新**:App 送出後端不認得的欄位/值時,後端是忽略還是 400? +> 三端都沒寫明這個契約。 +> - **登出/換帳號時的殘留**(從沒系統掃過):第二十四/二十八輪各修過一個 +> 「上一位使用者的資料留著」,但沒有人把 `_clearSession` 的欄位與 +> controller 的所有狀態欄位對過一次帳。 + > > 1. **`gh pr list`(三個 repo)照樣是第一件事**——本輪開工時皆為 0。 > 2. **第十一次實查三個外部卡點:依然都不在**。 diff --git a/lib/customer/customer_controller.dart b/lib/customer/customer_controller.dart index b23a3de..8aade7c 100644 --- a/lib/customer/customer_controller.dart +++ b/lib/customer/customer_controller.dart @@ -55,6 +55,10 @@ class CustomerController extends ChangeNotifier { String? _error; bool _busy = false; bool _wsConnected = false; + + /// 這條 session 曾經連上過 WS。用來分辨「第一次連上」與「重連」—— + /// 只有後者需要對帳(第一次連上時 init/登入路徑才剛問過)。 + bool _everConnected = false; Position? _lastPosition; CustomerRide? _activeRide; // 最近一筆進行中訂單的鏡像:即使輪詢對帳先把 _activeRide 清成 null,仍能在稍後才到的 @@ -792,8 +796,16 @@ class CustomerController extends ChangeNotifier { _ws = _wsFactory( onEvent: _handleWsEvent, onConnectionChanged: (connected) { + // 與司機端同一個理由:WS 重連不補送斷線期間的事件。乘客端有輪詢, + // 但**輪詢只在有進行中訂單時才跑**(`_applyActiveRide` 沒有單就 `_stopPolling`)—— + // 所以「閒置時另一台裝置叫了車」這條(第二十八輪修的那個情境) + // 若剛好卡在斷線視窗裡,事件收不到、輪詢也沒開,就沒有任何人會發現。 + final reconnected = connected && !_wsConnected && _everConnected; _wsConnected = connected; + if (connected) _everConnected = true; notifyListeners(); + // 第一次連上不對帳——`init()`/登入路徑才剛 `refreshActive` 過。 + if (reconnected) unawaited(refreshActive(silent: true)); }, ); final saved = await _storage.read(); diff --git a/lib/driver/driver_controller.dart b/lib/driver/driver_controller.dart index 47031ea..29f64ed 100644 --- a/lib/driver/driver_controller.dart +++ b/lib/driver/driver_controller.dart @@ -16,7 +16,8 @@ import '../core/storage/token_storage.dart'; import '../core/ws/fleet_ws_client.dart'; /// 定位串流的來源;測試以此換掉真 GPS(比照 `FleetWsClientFactory`)。 -typedef DriverPositionStreamFactory = Stream Function(LocationSettings); +typedef DriverPositionStreamFactory = + Stream Function(LocationSettings); /// 上線前的權限確認(可能會彈系統視窗);測試以此換掉平台對話框。 typedef DriverLocationPermissionCheck = Future Function(); @@ -51,15 +52,15 @@ class DriverController extends ChangeNotifier { DriverPositionStreamFactory? positionStream, DriverLocationPermissionCheck? locationPermissions, DriverLocationPermissionProbe? locationPermissionProbe, - }) : _storage = storage ?? TokenStorage(), - _api = api ?? FleetApiClient(), - _wsFactory = wsFactory ?? FleetWsClient.new, - _push = push ?? NoOpFleetPushService(), - _positionStream = positionStream ?? _geolocatorPositionStream, - _ensurePermissions = - locationPermissions ?? ensureDriverLocationPermissions, - _permissionProbe = locationPermissionProbe ?? _geolocatorPermissionProbe, - _ws = FleetWsClient(onEvent: (_) {}) { + }) : _storage = storage ?? TokenStorage(), + _api = api ?? FleetApiClient(), + _wsFactory = wsFactory ?? FleetWsClient.new, + _push = push ?? NoOpFleetPushService(), + _positionStream = positionStream ?? _geolocatorPositionStream, + _ensurePermissions = + locationPermissions ?? ensureDriverLocationPermissions, + _permissionProbe = locationPermissionProbe ?? _geolocatorPermissionProbe, + _ws = FleetWsClient(onEvent: (_) {}) { // token 過期/失效時把司機送回登入頁(見 _handleUnauthorized)。 _api.onUnauthorized = _handleUnauthorized; } @@ -79,6 +80,10 @@ class DriverController extends ChangeNotifier { bool _errorIsConnectivity = false; bool _online = false; bool _wsConnected = false; + + /// 這條 session 曾經連上過 WS。用來分辨「第一次連上」與「重連」—— + /// 只有後者需要對帳(第一次連上時 `init()` 才剛問過)。 + bool _everConnected = false; RideOffer? _pendingOffer; ActiveRide? _activeRide; Position? _lastPosition; @@ -137,6 +142,7 @@ class DriverController extends ChangeNotifier { _error = e.message; _errorIsConnectivity = e.statusCode == null; } + bool get online => _online; bool get wsConnected => _wsConnected; @@ -157,6 +163,7 @@ class DriverController extends ChangeNotifier { return DateTime.now().difference(since) > const Duration(seconds: AppConfig.driverOfflineSec); } + RideOffer? get pendingOffer => _pendingOffer; ActiveRide? get activeRide => _activeRide; Position? get lastPosition => _lastPosition; @@ -172,13 +179,17 @@ class DriverController extends ChangeNotifier { List get lostItems => _lostItems; /// 標記已到達某停靠點(N7)。成功回 true。 - Future markStopArrived(int stopId) => _markStop(stopId, _api.arriveStop); + Future markStopArrived(int stopId) => + _markStop(stopId, _api.arriveStop); /// 標記跳過某停靠點(乘客未出現,N7)。成功回 true。 /// **被跳過的站不計入車資**——後端 N5 的計費路線會排除它。 Future markStopSkipped(int stopId) => _markStop(stopId, _api.skipStop); - Future _markStop(int stopId, Future Function(int, int) action) async { + Future _markStop( + int stopId, + Future Function(int, int) action, + ) async { final ride = _activeRide; if (ride == null) return false; _busy = true; @@ -320,6 +331,7 @@ class DriverController extends ChangeNotifier { } notifyListeners(); } + String? get fcmTokenPrefix { final t = _fcmToken; if (t == null || t.length <= 8) return t; @@ -330,8 +342,18 @@ class DriverController extends ChangeNotifier { _ws = _wsFactory( onEvent: _handleWsEvent, onConnectionChanged: (connected) { + // **重連之後要跟後端對一次帳**:WS 重連不補送斷線期間的事件(見第四輪), + // 而司機端**沒有任何行程輪詢**(只有定位健康度那支 timer), + // `onAppResumed` 又要 App 真的進過背景——開車時 App 全程在前景,走不到。 + // 少了這一段,斷線視窗裡的 `ride.cancelled` 就永遠送不到: + // 司機會繼續開往一個已經取消的上車點。 + // 乘客端不會踩到,因為它有進行中訂單時每 15 秒輪詢一次,天然會校正。 + final reconnected = connected && !_wsConnected && _everConnected; _wsConnected = connected; + if (connected) _everConnected = true; notifyListeners(); + // 第一次連上不對帳——`init()` 才剛 `_restoreActiveRide` 過,再問只是多打一支。 + if (reconnected) unawaited(_restoreActiveRide(silent: true)); }, ); final saved = await _storage.read(); @@ -462,10 +484,9 @@ class DriverController extends ChangeNotifier { required String lineUserId, required String password, }) async { - await _authenticate(() => _api.login( - lineUserId: lineUserId, - password: password, - )); + await _authenticate( + () => _api.login(lineUserId: lineUserId, password: password), + ); } Future register({ @@ -473,11 +494,10 @@ class DriverController extends ChangeNotifier { required String name, required String password, }) async { - await _authenticate(() => _api.register( - lineUserId: lineUserId, - name: name, - password: password, - )); + await _authenticate( + () => + _api.register(lineUserId: lineUserId, name: name, password: password), + ); } Future _authenticate(Future Function() action) async { @@ -661,10 +681,9 @@ class DriverController extends ChangeNotifier { await _stopLocationStream(); if (!_online || _session == null) return; - _positionSub = _positionStream(driverLocationSettings()).listen( - (pos) => _reportPosition(pos), - onError: _handlePositionError, - ); + _positionSub = _positionStream( + driverLocationSettings(), + ).listen((pos) => _reportPosition(pos), onError: _handlePositionError); _startLocationHealthTimer(); // 立即回報一筆,不必等第一個 stream tick;不 await 以免上線鈕卡在等 GPS fix。 @@ -1002,9 +1021,11 @@ class DriverController extends ChangeNotifier { Future> fetchMessages(int rideId, {int afterId = 0}) => _api.fetchMessages(rideId, afterId: afterId); - Future sendMessage(int rideId, String body, - {String? clientMsgId}) => - _api.sendMessage(rideId, body, clientMsgId: clientMsgId); + Future sendMessage( + int rideId, + String body, { + String? clientMsgId, + }) => _api.sendMessage(rideId, body, clientMsgId: clientMsgId); /// 重新拉未結案協尋工作清單(登入後、遺失物頁下拉)。 Future refreshLostItems() async { @@ -1018,28 +1039,31 @@ class DriverController extends ChangeNotifier { } /// 標記已尋獲(open → found)。 - Future markLostItemFound(int itemId, {required int rideId}) => - _writeLostItem( - () => _api.markLostItemFound(itemId), - rideId: rideId, - // 「已經越過 open」=這次標記生效了(乘客可能已接著付款)。 - // 不能只寫 `!= open`:被結案(closed)也符合那個條件,但那不是標尋獲。 - applied: (fresh) => - fresh.id == itemId && - (fresh.status == LostItemStatus.found || - fresh.status == LostItemStatus.paid || - fresh.status == LostItemStatus.returned), - ); + Future markLostItemFound( + int itemId, { + required int rideId, + }) => _writeLostItem( + () => _api.markLostItemFound(itemId), + rideId: rideId, + // 「已經越過 open」=這次標記生效了(乘客可能已接著付款)。 + // 不能只寫 `!= open`:被結案(closed)也符合那個條件,但那不是標尋獲。 + applied: (fresh) => + fresh.id == itemId && + (fresh.status == LostItemStatus.found || + fresh.status == LostItemStatus.paid || + fresh.status == LostItemStatus.returned), + ); /// 付訖後標記已歸還(paid → returned)。 - Future markLostItemReturned(int itemId, - {required int rideId}) => - _writeLostItem( - () => _api.markLostItemReturned(itemId), - rideId: rideId, - applied: (fresh) => - fresh.id == itemId && fresh.status == LostItemStatus.returned, - ); + Future markLostItemReturned( + int itemId, { + required int rideId, + }) => _writeLostItem( + () => _api.markLostItemReturned(itemId), + rideId: rideId, + applied: (fresh) => + fresh.id == itemId && fresh.status == LostItemStatus.returned, + ); /// 未尋獲結案(open/found → closed)。 Future closeLostItem(int itemId, {required int rideId}) => @@ -1167,8 +1191,9 @@ class DriverController extends ChangeNotifier { final dropoff = event.payload?['dropoff_address'] as String?; _activeRide = _activeRide!.copyWith( phase: DriverRidePhase.enRouteToPickup, - dropoffAddress: - (dropoff != null && dropoff.isNotEmpty) ? dropoff : null, + dropoffAddress: (dropoff != null && dropoff.isNotEmpty) + ? dropoff + : null, dropoffLat: (event.payload?['dropoff_lat'] as num?)?.toDouble(), dropoffLng: (event.payload?['dropoff_lng'] as num?)?.toDouble(), ); diff --git a/test/customer_multi_device_test.dart b/test/customer_multi_device_test.dart index e44bf04..572c5bc 100644 --- a/test/customer_multi_device_test.dart +++ b/test/customer_multi_device_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:line_fleet_app/core/api/customer_api_client.dart'; import 'package:line_fleet_app/core/config/app_config.dart'; import 'package:line_fleet_app/core/models/models.dart'; +import 'package:line_fleet_app/core/storage/customer_token_storage.dart'; import 'package:line_fleet_app/core/ws/fleet_ws_client.dart'; import 'package:line_fleet_app/customer/customer_controller.dart'; @@ -38,6 +39,22 @@ class _TwoDeviceApi extends CustomerApiClient { Future> fetchLostItems() async => const []; } +/// 讓測試自己驅動連線狀態的假 WS(與 driver_ws_reconnect_test 同一招)。 +class _CapturingWs extends FleetWsClient { + _CapturingWs({required super.onEvent, super.onConnectionChanged}); + + @override + Future connect(String url, {String? token}) async {} + + @override + void ensureConnected() {} + + @override + Future disconnect() async {} + + void setConnected(bool connected) => onConnectionChanged?.call(connected); +} + CustomerController _deviceB(_TwoDeviceApi api) { final ctrl = CustomerController(api: api); ctrl.setSessionForTest( @@ -47,6 +64,8 @@ CustomerController _deviceB(_TwoDeviceApi api) { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('同一帳號的第二台裝置', () { test('B 台閒置時 A 台叫車:B 台收到事件要去跟後端對一次帳', () async { final api = _TwoDeviceApi(); @@ -144,6 +163,51 @@ void main() { expect(ctrl.activeRide?.rideId, 42, reason: '這一趟不該被別的單的事件動到'); }); + test('斷線視窗裡叫的車:WS 重連後 B 台仍然要跟上', () async { + final api = _TwoDeviceApi(); + late _CapturingWs ws; + final ctrl = CustomerController( + api: api, + // init() 會讀 session;不給記憶體版就會打到真的 secure storage + // (沒有 platform channel,直接炸)。 + storage: _MemoryCustomerStorage() + ..save( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ), + wsFactory: ({required onEvent, onConnectionChanged}) { + ws = _CapturingWs( + onEvent: onEvent, + onConnectionChanged: onConnectionChanged, + ); + return ws; + }, + ); + addTearDown(ctrl.dispose); + await ctrl.init(); + ws.setConnected(true); // 第一次連上 + await ctrl.refreshActive(); + expect(ctrl.activeRide, isNull, reason: '前置條件:B 台停在叫車表單'); + + // 斷線。上一案靠的是「事件送到 B 台」,但事件**送不到**斷線中的裝置, + // 而輪詢在沒有進行中訂單時是停著的——沒有任何人會發現。 + ws.setConnected(false); + api.backendActive = const CustomerRide( + rideId: 42, + status: RideStatus.accepted, + ); + final callsBefore = api.activeCalls; + + ws.setConnected(true); + await Future.delayed(Duration.zero); + + expect( + api.activeCalls, + greaterThan(callsBefore), + reason: '重連後要補一次對帳,這是斷線視窗唯一的補救', + ); + expect(ctrl.activeRide?.rideId, 42); + }); + test('位置串流與無 rideId 的事件不觸發對帳(避免連續打點)', () async { final api = _TwoDeviceApi(); final ctrl = _deviceB(api); @@ -167,3 +231,16 @@ void main() { }); }); } + +class _MemoryCustomerStorage extends CustomerTokenStorage { + CustomerSession? _saved; + + @override + Future read() async => _saved; + + @override + Future save(CustomerSession session) async => _saved = session; + + @override + Future clear() async => _saved = null; +} diff --git a/test/driver_ws_reconnect_test.dart b/test/driver_ws_reconnect_test.dart new file mode 100644 index 0000000..fce1380 --- /dev/null +++ b/test/driver_ws_reconnect_test.dart @@ -0,0 +1,141 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:line_fleet_app/core/api/fleet_api_client.dart'; +import 'package:line_fleet_app/core/config/app_config.dart'; +import 'package:line_fleet_app/core/models/models.dart'; +import 'package:line_fleet_app/core/storage/token_storage.dart'; +import 'package:line_fleet_app/core/ws/fleet_ws_client.dart'; +import 'package:line_fleet_app/driver/driver_controller.dart'; + +/// 司機端**沒有任何行程輪詢**(只有定位健康度那支 timer),而 WS 重連 +/// **不會補送**斷線期間的事件(見 TODO 第四輪)。所以斷線視窗裡發生的每一件事 +/// 都要靠某個人主動去對帳——本檔釘的就是「重連之後有沒有人去問」。 +/// +/// 乘客端的輪詢**只在有進行中訂單時**才跑,所以它也有同一個洞的一半(本輪一併補)。 +class _DriverApi extends FleetApiClient { + _DriverApi() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + /// 後端當下的「這位司機的進行中訂單」。null = 沒有(例如乘客取消了)。 + ActiveRide? backendActive; + int activeCalls = 0; + + @override + Future activeRide() async { + activeCalls++; + return backendActive; + } + + @override + Future> fetchLostItems() async => const []; + + @override + Future fetchVehicle() async => const DriverVehicle( + vehicleType: 'sedan', + plateNumber: 'TEST-01', + hasVehicle: true, + canAccept: true, + reviewStatus: VehicleReviewStatus.approved, + ); +} + +/// 讓測試自己驅動連線狀態的假 WS。 +class _CapturingWs extends FleetWsClient { + _CapturingWs({required super.onEvent, super.onConnectionChanged}); + + @override + Future connect(String url, {String? token}) async {} + + @override + void ensureConnected() {} + + @override + Future disconnect() async {} + + /// 模擬連線狀態變化(真 client 由 socket 事件觸發同一支 callback)。 + void setConnected(bool connected) => onConnectionChanged?.call(connected); +} + +void main() { + group('司機端 WS 重連後的對帳', () { + late _CapturingWs ws; + + Future driver(_DriverApi api) async { + final ctrl = DriverController( + storage: MemoryDriverAuthStore() + ..save(const AuthSession(driverId: 7, token: 'tok')), + api: api, + wsFactory: ({required onEvent, onConnectionChanged}) { + ws = _CapturingWs( + onEvent: onEvent, + onConnectionChanged: onConnectionChanged, + ); + return ws; + }, + ); + await ctrl.init(); + return ctrl; + } + + test('斷線期間乘客取消了:重連後司機要知道,不能繼續開往上車點', () async { + final api = _DriverApi() + ..backendActive = ActiveRide( + rideId: 42, + address: '台北車站', + phase: DriverRidePhase.enRouteToPickup, + ); + final ctrl = await driver(api); + addTearDown(ctrl.dispose); + expect(ctrl.activeRide?.rideId, 42, reason: '前置條件:司機手上有這趟'); + + // 第一次連上(production 一定會走到這一步:上線時 WS 才接起來)。 + ws.setConnected(true); + + // 斷線。這段期間乘客取消 → 後端已無進行中訂單, + // 而 `ride.cancelled` 這則事件**送不到**(WS 重連不補送)。 + ws.setConnected(false); + api.backendActive = null; + final callsBefore = api.activeCalls; + + // 重連。 + ws.setConnected(true); + await Future.delayed(Duration.zero); + + // 修改前:`onConnectionChanged` 只記旗標、notifyListeners,沒有人去問後端; + // 而司機端沒有輪詢、`onAppResumed` 又要 App 真的進過背景—— + // 一個全程開在前景的司機(開車時的常態)會**繼續開往一個已經取消的上車點**。 + expect( + api.activeCalls, + greaterThan(callsBefore), + reason: '重連後要跟後端對一次帳,斷線期間漏掉的事件只能靠這個補', + ); + expect(ctrl.activeRide, isNull, reason: '那趟已經沒了,行程卡不該還留著'); + }); + + test('第一次連上不重複對帳(init 剛問過)', () async { + final api = _DriverApi(); + final ctrl = await driver(api); + addTearDown(ctrl.dispose); + final callsAfterInit = api.activeCalls; + + // 冷啟動時 WS 第一次連上:init() 才剛 `_restoreActiveRide` 過, + // 這裡再問一次只是多打一支 API。 + ws.setConnected(true); + await Future.delayed(Duration.zero); + + expect(api.activeCalls, callsAfterInit); + }); + + test('斷線本身不觸發對帳(問了也問不到)', () async { + final api = _DriverApi(); + final ctrl = await driver(api); + addTearDown(ctrl.dispose); + final callsBefore = api.activeCalls; + + ws.setConnected(false); + await Future.delayed(Duration.zero); + + expect(api.activeCalls, callsBefore); + }); + }); +}