diff --git a/README.md b/README.md index 4fdbd3e..fe79acc 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。 詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。 -**目前**:`flutter analyze` 無 issue、`flutter test` **377 passed**(50 個測試檔,2026-07-30 實跑)。 -~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 +**目前**:`flutter analyze` 無 issue、`flutter test` **414 passed**(54 個測試檔,2026-07-31 實跑)。 +~~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)—— @@ -293,6 +293,11 @@ SnackBar;FCM token 輪替失敗會冒出司機看不懂也無事可做的紅 其中「系統定位服務被關」原本是**完全靜默**的(例外穿出 `placeOrder`,畫面一句話都沒有)。 另外,**多停靠點行程不再需要裝置定位**:pickup/dropoff 由 stops 推導,後端本來就不看那組座標。 兩端都在 `m6_pixel` 上以 `settings put secure location_mode 0` 實跑驗過。 +**第二十三輪**再補一個真裝置才會發生的洞:**App 被系統收掉(或司機自己滑掉)再開時, +行程會被還原,但定位回報整段消失**——乘客端的司機 marker 定格、抵達圍籬不觸發、 +里程軌跡缺一段,而司機看到的只是 hero 上那句「離線」。冷啟動還原到進行中行程時會接回回報 +(只在已有權限時、且不在回前景時做,那顆離線鈕要按得掉)。 +同一台裝置、同一張行程跑過修改前後兩個版本:修好的版本重開 8 秒後恢復回報,修改前 40 秒零筆。 - **預約司機+常用地點(2026-07-31)**:乘客可預約**未來**的用車,以及把住家/公司等 常去的地點存起來,叫車與預約時一鍵帶入。 diff --git a/docs/TODO.md b/docs/TODO.md index c1eac41..81cc002 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -73,6 +73,7 @@ - [🔦 第十九輪:司機端協尋畫面的實跑證據(沒抓到 bug,補了負向對照)](#-2026-07-30-第十九輪司機端協尋畫面的實跑證據沒抓到-bug補了一個負向對照) - [📍 第二十一輪:定位串流死掉時,畫面永遠不會知道](#-2026-07-30-第二十一輪定位串流死掉時畫面永遠不會知道本批修掉) - [📍 第二十二輪:乘客端的定位出口(+第二十一輪的實跑尾巴)](#-2026-07-30-第二十二輪乘客端的定位出口本批修掉-31-個並補完第二十一輪的實跑尾巴) +- [🚗 第二十三輪:App 被收掉再開,行程還在但位置回報整段消失](#-2026-07-30-第二十三輪app-被收掉再開行程還在但位置回報整段消失本批修掉-2-個) - [🗓️ 預約司機+常用地點(跨端新功能,含 debug 兩個)](#-預約司機常用地點2026-07-31跨端新功能) **四、維護、決策與待辦** @@ -90,7 +91,7 @@ - **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。 **登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。 - **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。 -- 單元測試:**53 個測試檔、`flutter test` 408 passed**(2026-07-31 預約司機那批實跑;`flutter analyze` 無 issue)。 +- 單元測試:**54 個測試檔、`flutter test` 414 passed**(2026-07-31 第二十三輪與預約司機那批合併後實測;`flutter analyze` 無 issue)。 ~~(54 項)~~ 是 2026-07-10 的數字,長期沒更新,已更正——**本節的數字請跟著最後盤點日一起改**。 - 遠端:`github.com/thothawei/fleet-app`。**2026-07-29 實查**:`git ls-remote --heads origin` 只有 `main`、 `gh pr list` 三個 repo 的 open PR 皆為 0(開工前請自己再跑一次,見「下次任務」第 1 點)。 @@ -2090,6 +2091,65 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log --- +## 🚗 2026-07-30 第二十三輪:App 被收掉再開,行程還在但位置回報整段消失(本批修掉 2 個) + +> 「定位健康度」這一族的最後一個角落。原本 TODO 寫的是「前景服務被系統收走時 App 端 +> 沒有任何偵測」——**查下去發現那句話要修正**:前景服務被收走而 App 還活著時, +> 串流會停,第二十一輪的定期重評已經把它接住了(第二十二輪實測 12~24 秒降級)。 +> 真正沒有網的是**行程被還原、但沒有人把定位回報接回來**。 + +### 根因一句話 + +`init()` 會用 `_restoreActiveRide()` 把進行中行程還原(行程卡照樣顯示「導航/已上車/完成」), +但 `_online` 一律從 false 起、定位串流也沒起來——**整趟零位置回報**。 +而 hero 只寫「離線/目前不會收到派單」:在載客途中,那句話講的是接不接**新單**, +司機不會從它聯想到「乘客看不到我在動」。 + +**後果不在司機這一端**:乘客端的司機 marker 定格、ETA 不再更新; +後端的抵達圍籬(靠司機座標自動觸發)不會觸發;F3 里程計價的「軌跡 vs 路線取大者」 +缺一整段軌跡。觸發條件很日常:省電模式或廠商清背景把 App 收掉、司機自己從最近工作清單滑掉。 + +### 修法(兩個) + +1. **冷啟動恢復回報**:`init()` 還原到進行中行程時接回定位回報。三個限制條件都寫在碼裡—— + - **只在冷啟動做**:App 還活著時 `_online == false` 一定是司機自己按的, + 回前景也自動上線的話那顆離線鈕就按不掉了(`onAppResumed` 刻意不做)。 + - **只在已經有權限時做**:冷啟動就彈系統權限視窗太侵入(司機可能只是想看歷史)。 + 沒權限時留一則可行動的訊息(「需要定位權限才能把位置回報給乘客」, + **不是**「才能上線」——行程中的司機看到後者會以為只影響接新單)。 + - **沒有行程就不自動上線**:接不接單是司機的決定。 +2. **`refreshVehicle()` 不再無條件清錯誤**:它原本成功時就 `_setError(null)`, + 於是排在它前面設好的訊息全被洗掉——上面那句權限提示就是這樣消失的(設了、也通知了, + 下一行清空)。改成**只清自己造成的那則**(`_vehicleLoadFailed` 為真才清)。 + 同一種病第二十一輪在位置回報探針上修過一次:**清錯誤要指名清哪一則。** + +### 驗收 + +- `flutter analyze` 無 issue、`flutter test` **383 passed**(377 +新 6)。 + **與預約司機那批合併後重測:414 passed、54 個測試檔**(兩批的測試互不干擾)。 +- **反向驗證兩半各一次**:拿掉冷啟動恢復 → 2 案 FAIL;讓 `refreshVehicle` 回去無條件清錯誤 → 2 案 FAIL。 +- **模擬器實跑,同一台裝置、同一張行程 #37,兩個版本各跑一次**(這是本輪最強的證據): + +| 時刻 | 事件 | 後端收到的位置回報 | +|---|---|---| +| 23:21:29–23:22:22 | 上線+接單 #37 | 每 8 秒一筆 | +| 23:22:25 | `am force-stop`(模擬系統收掉 App) | 停止 | +| 23:23:15 | **修好的版本**冷啟動 | **23:23:23 起恢復**,畫面 hero「上線中」+行程卡 | +| 23:24:41 | **修改前的版本**冷啟動(同一張單) | **40 秒零筆**,畫面行程卡完整、hero 卻寫「離線」 | + +- **負向對照**:沒有進行中行程時冷啟動 → 後端零位置回報(沒有偷偷幫司機上線)。 + +### 這一輪學到的兩條 + +1. **接縫的預設實作會跑進測試環境**:我一開始讓冷啟動直接呼叫權限確認, + 結果 `driver_home_widget_test` 從 4.8 秒變成 **7 分鐘以上**——測試環境沒有 platform channel, + 那支呼叫的 Future **永遠不會完成**,`init()` await 下去就把測試卡到 timeout。 + 預設實作現在會先看 `FLUTTER_TEST` 直接回「查不到」。 + **判準**:新增任何「`init()` 期間 await 平台能力」的程式碼,先問它在測試環境會回什麼。 +2. **「查不到」與「被拒絕」要分開**:權限探針回三態(`LocationPermission?`), + `null` 什麼都不做、`denied` 才留訊息。混成 bool 的話,測試環境會冒出一則假的權限錯誤。 + +--- ## 🗓️ 預約司機+常用地點(2026-07-31,跨端新功能) > 需求:乘客可以**預約未來的用車**(不是現在叫車),以及把**住家/公司等常去的地點存起來**, @@ -2435,9 +2495,18 @@ PSQL_DSN='postgres://fleet:change_me@127.0.0.1:5433/fleet?sslmode=disable' \ > (靜默、`deniedForever` 文案沒出路、多停靠點被不必要地擋住)。 > 順帶查清一件事:**地圖跟隨完全不吃裝置定位**(司機 marker 來自 WS, > `lastPosition` 為 null 時退回台北市中心),所以那條路徑沒有出口要補。 -> 2. **前景服務被系統收走**(Android 省電模式殺掉常駐通知): -> 現在只有定位串流的 onError 會知道,服務本身消失時 App 端沒有任何偵測。 -> **這是本族目前唯一剩下的角落**,也是下一輪的首選。 +> 2. ~~**前景服務被系統收走**(Android 省電模式殺掉常駐通知)~~ +> ✅ **已於第二十三輪查清並修掉**,但**原本的描述要更正**: +> 前景服務被收走而 App 還活著時,串流會停,第二十一輪的定期重評已經接住了 +> (第二十二輪實測 12~24 秒降級)。真正沒有網的是**App 被收掉再開**—— +> 行程會被還原,但沒有人把定位回報接回來,整趟零回報。詳見第二十三輪專段。 +> +> **➡️ 定位健康度這一族到此清完**(司機端串流健康度、乘客端叫車出口、冷啟動恢復回報)。 +> 下一輪請換族。讀碼時順手記下、還沒查的候選: +> - **hero 在行程中仍寫「等待派單中」**(第二十三輪實跑畫面看到):司機已經在載客, +> 那句話講的是接新單,語意不精確。屬文案/狀態顯示,不影響功能。 +> - **司機端沒有任何輪詢**:WS 漏事件時只有回前景與操作被 409 打回兩條補救路徑。 +> 第四輪修過回前景對帳,但**長時間停留在前景**的漏事件情境沒有網。 > > **第二十輪順帶學到的一條**(已寫進坑卡):反向驗證要還原檔案時, > **先 `cp` 一份再改、改完從備份還原**——這一輪順手打了 `git checkout -- `, diff --git a/lib/driver/driver_controller.dart b/lib/driver/driver_controller.dart index c42a330..47031ea 100644 --- a/lib/driver/driver_controller.dart +++ b/lib/driver/driver_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; @@ -17,9 +18,29 @@ import '../core/ws/fleet_ws_client.dart'; /// 定位串流的來源;測試以此換掉真 GPS(比照 `FleetWsClientFactory`)。 typedef DriverPositionStreamFactory = Stream Function(LocationSettings); +/// 上線前的權限確認(可能會彈系統視窗);測試以此換掉平台對話框。 +typedef DriverLocationPermissionCheck = Future Function(); + Stream _geolocatorPositionStream(LocationSettings settings) => Geolocator.getPositionStream(locationSettings: settings); +/// 「現在的定位權限」——**只查不請求**,冷啟動自動恢復回報時用。 +/// `null` = 查不到(平台不可用,例如單元測試沒有 platform channel): +/// **查不到與被拒絕是兩件事**,前者什麼都不該做,後者要跟司機說一聲。 +typedef DriverLocationPermissionProbe = Future Function(); + +Future _geolocatorPermissionProbe() async { + // 測試環境沒有 platform channel,這支呼叫的 Future **永遠不會完成**—— + // `init()` await 下去就把整個測試卡到 timeout(實測 driver_home_widget_test + // 從 4.8 秒變成 7 分鐘以上)。「查不到」正是這裡該有的答案。 + if (Platform.environment.containsKey('FLUTTER_TEST')) return null; + try { + return await Geolocator.checkPermission(); + } catch (_) { + return null; + } +} + /// 司機端狀態:登入、上線、WS 派單、行程操作。 class DriverController extends ChangeNotifier { DriverController({ @@ -28,11 +49,16 @@ class DriverController extends ChangeNotifier { FleetWsClientFactory? wsFactory, FleetPushService? push, 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: (_) {}) { // token 過期/失效時把司機送回登入頁(見 _handleUnauthorized)。 _api.onUnauthorized = _handleUnauthorized; @@ -43,6 +69,8 @@ class DriverController extends ChangeNotifier { final FleetWsClientFactory _wsFactory; final FleetPushService _push; final DriverPositionStreamFactory _positionStream; + final DriverLocationPermissionCheck _ensurePermissions; + final DriverLocationPermissionProbe _permissionProbe; FleetWsClient _ws; AuthSession? _session; @@ -222,8 +250,12 @@ class DriverController extends ChangeNotifier { if (_session == null) return; try { _vehicle = await _api.fetchVehicle(); + // **只清自己造成的那則**:這裡原本無條件 `_setError(null)`,於是任何排在 + // 它前面設好的訊息都會被洗掉——`init()` 裡「需要定位權限才能把位置回報給乘客」 + // 就是這樣消失的(設了、也通知了,下一行就被抹掉,畫面上什麼都沒有)。 + // 同一種病第二十一輪在位置回報探針上修過一次:清錯誤要指名清哪一則。 + if (_vehicleLoadFailed) _setError(null); _vehicleLoadFailed = false; - _setError(null); } on ApiException catch (e) { _setApiError(e); _vehicleLoadFailed = true; @@ -306,6 +338,7 @@ class DriverController extends ChangeNotifier { if (saved != null) { await _applySession(saved); await _restoreActiveRide(); + await _resumeReportingForRestoredRide(); await refreshLostItems(); // O3 gate 的 App 端引導:一還原 session 就查車輛,_DriverRoot 才知道要不要跳設定頁。 await refreshVehicle(); @@ -374,6 +407,39 @@ class DriverController extends ChangeNotifier { } } + /// 冷啟動還原到進行中行程時,把定位回報接回來。 + /// + /// **這是行程中位置回報唯一會整段消失的路徑**:App 被系統收掉(省電模式殺前景服務、 + /// 廠商清背景),或司機自己把它從最近工作清單滑掉,再打開——`_restoreActiveRide` + /// 會把行程還原、行程卡照樣顯示「導航/已上車/完成」,但 `_online` 一律從 false 起、 + /// 定位串流也沒起來,於是**整趟零位置回報**:乘客端的司機 marker 定格、ETA 不再更新, + /// 後端的抵達圍籬不會觸發,F3 里程的軌跡缺一整段(車資會偏低)。 + /// 而 hero 只寫「離線/目前不會收到派單」——在載客途中,那句話講的是接不接新單, + /// 司機不會從它聯想到「乘客看不到我在動」。 + /// + /// **只在冷啟動做**:App 還活著時 `_online == false` 一定是司機自己按的, + /// 回前景時再自動上線等於那顆離線鈕按不掉。 + /// + /// **而且只在已經有權限時做**:冷啟動就彈系統權限視窗太侵入(司機可能只是想看歷史), + /// 沒權限時留一則可行動的訊息,等他自己按上線再彈。 + Future _resumeReportingForRestoredRide() async { + if (_session == null || _online || _activeRide == null) return; + const denied = '需要定位權限才能把位置回報給乘客'; + switch (await _permissionProbe()) { + case LocationPermission.always: + case LocationPermission.whileInUse: + await _goOnline(deniedMessage: denied); + case LocationPermission.denied: + case LocationPermission.deniedForever: + case LocationPermission.unableToDetermine: + // 不彈視窗,只說一聲——司機按上線時才會走到請求那條。 + _setError(denied); + notifyListeners(); + case null: + break; // 查不到權限狀態:不自動恢復,也不編故事。 + } + } + /// App 從背景回到前景(由 `AppLifecycleReactor` 呼叫)。 /// /// 司機端**沒有任何輪詢**:`ride.assigned`/`ride.cancelled`/`ride.completed` @@ -557,11 +623,13 @@ class DriverController extends ChangeNotifier { } } - Future goOnline() async { + Future goOnline() => _goOnline(deniedMessage: '需要定位權限才能上線'); + + Future _goOnline({required String deniedMessage}) async { if (_session == null) return; - final ok = await ensureDriverLocationPermissions(); + final ok = await _ensurePermissions(); if (!ok) { - _setError('需要定位權限才能上線'); + _setError(deniedMessage); notifyListeners(); return; } diff --git a/test/driver_resume_reporting_test.dart b/test/driver_resume_reporting_test.dart new file mode 100644 index 0000000..3f0be7e --- /dev/null +++ b/test/driver_resume_reporting_test.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.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'; + +/// App 被收掉後重開,行程還在——但位置回報整段消失。 +/// +/// 這是「定位健康度」這一族最後一個沒有網的角落:前景服務被系統收走、或司機自己 +/// 把 App 從最近工作清單滑掉,`_restoreActiveRide` 會把行程還原、行程卡照樣顯示 +/// 「導航/已上車/完成」,但 `_online` 一律從 false 起、定位串流也沒起來。 +/// 後果不在司機這一端:**乘客端的司機 marker 定格、後端的抵達圍籬不會觸發、 +/// F3 里程的軌跡缺一整段**,而司機看到的只是 hero 上那句「離線/目前不會收到派單」。 +void main() { + group('冷啟動還原到行程中', () { + test('要把定位回報接回來(不然整趟乘客都看不到司機在動)', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: true); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.activeRide?.rideId, 9, reason: '前置:行程有還原'); + expect(ctrl.online, isTrue, reason: '載客途中卻不回報位置=乘客端的司機定格'); + + gps.add(_pos(25.03)); + await Future.delayed(Duration.zero); + expect(api.reportCalls, greaterThan(0), + reason: '旗標打開卻沒接上串流,位置一樣送不出去'); + + await ctrl.goOffline(); + }); + + test('沒有行程就不自動上線(接不接單是司機的決定)', () async { + final api = _RideApi()..active = null; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: true); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.online, isFalse); + expect(api.reportCalls, 0); + }); + + test('查不到權限狀態(平台不可用)→ 什麼都不做,也不編故事', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = DriverController( + storage: MemoryDriverAuthStore() + ..save(const AuthSession(driverId: 7, token: 'tok')), + api: api, + wsFactory: FleetWsClient.silent, + positionStream: (_) => gps.stream, + locationPermissionProbe: () async => null, + ); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.online, isFalse); + expect(ctrl.error, isNull, reason: '「查不到」與「被拒絕」是兩件事'); + }); + + test('權限被拒 → 不上線、不彈視窗,但要講「回報給乘客」不是「上線」', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: false); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.online, isFalse); + expect(ctrl.error, contains('乘客'), + reason: '行程中的司機看到「才能上線」會以為那只影響接新單'); + }); + + test('司機自己按了離線就不再自動接回來(那顆鈕要按得掉)', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: true); + addTearDown(ctrl.dispose); + await ctrl.init(); + await ctrl.goOffline(); + expect(ctrl.online, isFalse, reason: '前置:司機按下離線'); + + // 回前景會再對帳一次行程——這條路徑**不可以**順手把他重新上線。 + await ctrl.onAppResumed(); + + expect(ctrl.online, isFalse); + }); + }); +} + +final _ride = const ActiveRide( + rideId: 9, + address: '台北車站', + phase: DriverRidePhase.onTrip, +); + +Position _pos(double lat) => Position( + latitude: lat, + longitude: 121.56, + timestamp: DateTime.now(), + accuracy: 5, + altitude: 0, + altitudeAccuracy: 0, + heading: 0, + headingAccuracy: 0, + speed: 0, + speedAccuracy: 0, + ); + +DriverController _driver( + _RideApi api, + StreamController gps, { + required bool granted, + LocationPermission? probe, +}) => + DriverController( + storage: MemoryDriverAuthStore() + ..save(const AuthSession(driverId: 7, token: 'tok')), + api: api, + wsFactory: FleetWsClient.silent, + positionStream: (_) => gps.stream, + locationPermissions: () async => granted, + locationPermissionProbe: () async => + probe ?? + (granted + ? LocationPermission.whileInUse + : LocationPermission.denied), + ); + +class _RideApi extends FleetApiClient { + _RideApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + ActiveRide? active; + int reportCalls = 0; + + @override + void setToken(String? token) {} + + @override + Future activeRide() async => active; + + @override + Future reportLocation({required double lat, required double lng}) async { + reportCalls++; + } + + @override + Future> fetchLostItems() async => []; + + @override + Future fetchVehicle() async => const DriverVehicle( + vehicleType: 'sedan', + plateNumber: 'TEST-01', + hasVehicle: true, + canAccept: true, + ); +} diff --git a/test/driver_vehicle_test.dart b/test/driver_vehicle_test.dart index 11698de..3bc6f75 100644 --- a/test/driver_vehicle_test.dart +++ b/test/driver_vehicle_test.dart @@ -173,6 +173,27 @@ void main() { expect(ctrl.error, isNull); }); + test('查車輛成功不可以順手清掉別人設的錯誤', () async { + // 這裡原本是無條件 `_setError(null)`:`init()` 裡排在 refreshVehicle 前面 + // 設好的「需要定位權限才能把位置回報給乘客」就這樣被抹掉——設了、也通知了, + // 下一行清空,畫面上什麼都沒有。清錯誤要指名清哪一則。 + final denied = DriverController( + storage: MemoryDriverAuthStore(), + api: api, + wsFactory: FleetWsClient.silent, + locationPermissions: () async => false, + ); + addTearDown(denied.dispose); + await denied.init(); + await denied.login(lineUserId: 'U', password: 'pw'); + await denied.goOnline(); + expect(denied.error, isNotNull, reason: '前置:權限被拒的訊息掛上去了'); + + await denied.refreshVehicle(); + + expect(denied.error, isNotNull); + }); + test('儲存後以後端回傳值為準(車牌已正規化)', () async { await ctrl.init(); await ctrl.login(lineUserId: 'U', password: 'pw');