import 'dart:async'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/controller.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_menu_repository.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_recognition_gateway.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_sub_screen_gateway.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_verification_gateway.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/view.dart'; void main() { testWidgets('页面配置决定查询方式顺序和默认项,进入页面不自动识别', (tester) async { final recognition = _FakeRecognitionGateway(); final repository = _ImmediateRepository(_result('张*明', 1)); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: () async => const ReservationQueryPagePreferences( methodOrder: [ ReservationRecognitionMethod.face, ReservationRecognitionMethod.card, ], defaultMethod: ReservationRecognitionMethod.face, nameMaskEnabled: false, verificationEnabled: true, quickVerificationEnabled: true, selectedMealSlot: ReservationMealSlot.noon, verificationDateScope: ReservationVerificationDateScope.all, ), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); expect(controller.methodOrder, [ ReservationRecognitionMethod.face, ReservationRecognitionMethod.card, ]); expect(controller.method.value, ReservationRecognitionMethod.face); expect(controller.autoRecognitionEnabled.value, isFalse); expect(controller.continuousRecognitionEnabled.value, isTrue); expect(controller.nameMaskEnabled.value, isFalse); expect(controller.status.value, ReservationMenuQueryStatus.idle); expect(controller.verificationEnabled.value, isTrue); expect(controller.quickVerificationEnabled.value, isTrue); expect(controller.selectedMealSlot.value, ReservationMealSlot.noon); expect( controller.verificationDateScope.value, ReservationVerificationDateScope.all, ); expect(recognition.faceCallbacks, isEmpty); expect(recognition.cardCallbacks, isEmpty); await controller.startRecognition(); expect(recognition.faceCallbacks, hasLength(1)); await recognition.emitFaceAt(0, '1001'); expect(repository.calls.single.maskName, isFalse); await controller.clearForPrivacy(); }); testWidgets('恢复并缓存最近餐段,查询中和非常规餐段不会覆盖选择', (tester) async { final savedMealSlots = []; final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: _FakeRecognitionGateway(), subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: () async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, selectedMealSlot: ReservationMealSlot.noon, ), mealSlotSaver: (mealSlot) async => savedMealSlots.add(mealSlot), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); expect(controller.selectedMealSlot.value, ReservationMealSlot.noon); expect(controller.mealSlotC.value, {'2'}); controller.selectMealSlot(ReservationMealSlot.night); await tester.pump(); expect(controller.selectedMealSlot.value, ReservationMealSlot.night); expect(controller.mealSlotC.value, {'3'}); expect(savedMealSlots, [ReservationMealSlot.night]); controller.status.value = ReservationMenuQueryStatus.querying; controller.selectMealSlot(ReservationMealSlot.morning); controller.selectMealSlot(ReservationMealSlot.lateNight); await tester.pump(); expect(controller.selectedMealSlot.value, ReservationMealSlot.night); expect(savedMealSlots, [ReservationMealSlot.night]); }); testWidgets('快速核销只发布今天识别前预选餐段的核销弹框请求', (tester) async { final recognition = _FakeRecognitionGateway(); final requests = []; final errors = []; final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_resultWithBreakfastAndNoon()), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _quickVerificationPreferences, mealSlotSaver: (_) async {}, errorNotifier: errors.add, now: () => DateTime(2026, 7, 24, 11, 30), ); final subscription = controller.quickVerificationRequests.listen(requests.add); addTearDown(subscription.cancel); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); controller.selectMealSlot(ReservationMealSlot.noon); expect(controller.selectedMealSlot.value, ReservationMealSlot.noon); await controller.startRecognition(); await recognition.emitCardAt(0, 'CARD-001'); await tester.pump(); expect(requests, hasLength(1)); expect(requests.single.day.date, '2026-07-24'); expect(requests.single.meal.hourType, '2'); expect(requests.single.meal.orderIds, [2002]); expect(errors, isEmpty); await controller.clearForPrivacy(); }); testWidgets('快速核销没有当天预选餐段时保留结果并提示操作员', (tester) async { final recognition = _FakeRecognitionGateway(); final requests = []; final errors = []; final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_resultWithBreakfastAndNoon()), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _quickVerificationPreferences, mealSlotSaver: (_) async {}, errorNotifier: errors.add, now: () => DateTime(2026, 7, 24, 18), ); final subscription = controller.quickVerificationRequests.listen(requests.add); addTearDown(subscription.cancel); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); controller.selectMealSlot(ReservationMealSlot.night); await controller.startRecognition(); await recognition.emitCardAt(0, 'CARD-002'); await tester.pump(); expect(requests, isEmpty); expect(errors, ['当前用户今天没有可核销的晚餐预约']); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.result.value, isNotNull); await controller.clearForPrivacy(); }); testWidgets('持续自动饭卡在结果期继续读卡并用下一位结果覆盖', (tester) async { final repository = _SequenceRepository([ _result('张*明', 1), _result('李*华', 1), ]); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _autoCardPreferences, recognitionRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); expect(controller.autoRecognitionEnabled.value, isTrue); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(recognition.cardCallbacks, hasLength(1)); final first = recognition.emitCardAt(0, 'CARD-001'); final duplicate = recognition.emitCardAt(0, 'CARD-001'); await first; await duplicate; expect(repository.calls, hasLength(1)); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.result.value?.maskedName, '张*明'); expect(recognition.cardCallbacks, hasLength(2)); // 第一张卡未移开时可能被新一轮读到,但不应重复请求同一位用户。 await recognition.emitCardAt(1, 'CARD-001'); expect(repository.calls, hasLength(1)); expect(controller.result.value?.maskedName, '张*明'); await tester.pump(const Duration(milliseconds: 10)); expect(recognition.cardCallbacks, hasLength(3)); await recognition.emitCardAt(2, 'CARD-002'); expect(repository.calls, [ const _QueryCall(identityValue: 'CARD-001', maskName: true), const _QueryCall(identityValue: 'CARD-002', maskName: true), ]); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.result.value?.maskedName, '李*华'); expect(subScreen.updates.last.scenario, 'success'); expect(subScreen.updates.last.maskedName, '李*华'); expect(recognition.cardCallbacks, hasLength(4)); await controller.clearForPrivacy(); }); testWidgets('持续自动饭卡读到空卡时保留上次结果并继续等待', (tester) async { final repository = _ImmediateRepository(_result('张*明', 1)); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final errors = []; final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _autoCardPreferences, errorNotifier: errors.add, recognitionRestartDelay: const Duration(milliseconds: 10), recognitionFailureRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await recognition.emitCardAt(0, 'CARD-001'); expect(controller.result.value?.maskedName, '张*明'); expect(recognition.cardCallbacks, hasLength(2)); recognition.cardFailures[1]( const ReservationRecognitionFailure( ReservationRecognitionFailureType.identify, '未读取到有效饭卡,请重新刷卡', ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(repository.calls, hasLength(1)); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.result.value?.maskedName, '张*明'); expect(subScreen.updates.last.scenario, 'success'); expect(subScreen.updates.last.maskedName, '张*明'); expect(recognition.cardCallbacks, hasLength(3)); expect(errors, isEmpty); await controller.clearForPrivacy(); }); testWidgets('持续自动饭卡的新卡查询失败时保留上次结果', (tester) async { final repository = _SequenceRepository([ _result('张*明', 1), null, ]); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final errors = []; final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _autoCardPreferences, errorNotifier: errors.add, recognitionRestartDelay: const Duration(milliseconds: 10), recognitionFailureRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await recognition.emitCardAt(0, 'CARD-001'); await recognition.emitCardAt(1, 'CARD-002'); expect(repository.calls, hasLength(2)); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.result.value?.maskedName, '张*明'); expect(subScreen.updates.last.scenario, 'success'); expect(subScreen.updates.last.maskedName, '张*明'); expect(errors, ['未获取到预约查询结果']); await tester.pump(const Duration(milliseconds: 10)); expect(recognition.cardCallbacks, hasLength(3)); await controller.clearForPrivacy(); }); testWidgets('持续自动饭卡的新卡查询成功但无预约时清空旧菜单并提示', (tester) async { final repository = _SequenceRepository([ _result('张*明', 1), _result('李*华', 0), ]); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final errors = []; final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _autoCardPreferences, errorNotifier: errors.add, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await recognition.emitCardAt(0, 'CARD-001'); await recognition.emitCardAt(1, 'CARD-002'); expect(repository.calls, hasLength(2)); expect(controller.status.value, ReservationMenuQueryStatus.empty); expect(controller.result.value?.maskedName, '李*华'); expect(controller.result.value?.days, isEmpty); expect(subScreen.updates.last.scenario, 'empty'); expect(subScreen.updates.last.maskedName, '李*华'); expect(errors, ['当前用户暂无有效预约']); await controller.clearForPrivacy(); }); testWidgets('持续自动识别关闭时饭卡结果期不会重新布防读卡器', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoCardSingleRoundPreferences, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await recognition.emitCardAt(0, 'CARD-001'); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.result.value?.maskedName, '张*明'); expect(recognition.cardCallbacks, hasLength(1)); await controller.clearForPrivacy(); }); testWidgets('饭卡和人脸结果弹框展示期间暂停识别,关闭后才自动开始下一轮', (tester) async { for (final recognitionMethod in ReservationRecognitionMethod.values) { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: recognitionMethod == ReservationRecognitionMethod.card ? _autoCardPreferences : _autoFacePreferences, ); controller.onInit(); await tester.pump(); if (recognitionMethod == ReservationRecognitionMethod.card) { expect(recognition.cardCallbacks, hasLength(1)); await recognition.emitCardAt(0, 'CARD-001'); } else { expect(recognition.faceCallbacks, hasLength(1)); await recognition.emitFaceAt(0, '1001'); } expect(controller.status.value, ReservationMenuQueryStatus.success); expect(await controller.beginResultDialogPresentation(), isTrue); expect(await controller.beginResultDialogPresentation(), isFalse); expect(controller.remainingSeconds.value, 0); await controller.startRecognition(); await tester.pump(const Duration(seconds: 61)); if (recognitionMethod == ReservationRecognitionMethod.card) { expect(recognition.cardCallbacks, hasLength(2)); } else { expect(recognition.faceCallbacks, hasLength(1)); } expect(controller.result.value, isNotNull); await controller.endResultDialogPresentation(); expect(controller.result.value, isNull); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); if (recognitionMethod == ReservationRecognitionMethod.card) { expect(recognition.cardCallbacks, hasLength(3)); } else { expect(recognition.faceCallbacks, hasLength(2)); } controller.onClose(); } }); testWidgets('手动模式关闭菜单详情后保留当前结果并重新开始隐私倒计时', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _defaultPreferences, ); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitCardAt(0, 'CARD-001'); expect(controller.remainingSeconds.value, 60); expect(await controller.beginResultDialogPresentation(), isTrue); expect(controller.remainingSeconds.value, 0); await controller.endResultDialogPresentation(); expect(controller.result.value, isNotNull); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.remainingSeconds.value, 60); expect(recognition.cardCallbacks, hasLength(1)); controller.onClose(); }); testWidgets('自动人脸模式进入页面即启动,切换饭卡后自动启动新方式', (tester) async { final repository = _ImmediateRepository(_result('李*华', 1)); final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoFacePreferences, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); expect(controller.method.value, ReservationRecognitionMethod.face); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(recognition.faceCallbacks, hasLength(1)); expect(recognition.faceEmbeddedPreviews, [isTrue]); await recognition.emitFaceAt(0, '1001'); expect(repository.calls, [ const _QueryCall(identityValue: '1001', maskName: true), ]); expect(controller.status.value, ReservationMenuQueryStatus.success); await controller.selectMethod(ReservationRecognitionMethod.card); expect(controller.method.value, ReservationRecognitionMethod.card); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(recognition.cardCallbacks, hasLength(1)); }); testWidgets('持续人脸结果固定展示十秒并保留副屏组合页后识别下一位', (tester) async { var now = DateTime(2026, 8, 7, 10); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('李*华', 1)), recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _autoFacePreferences, continuousFaceResultDuration: const Duration(seconds: 10), now: () => now, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); expect(controller.continuousFaceRecognitionActive, isTrue); expect(recognition.faceEmbeddedPreviews, [isTrue]); expect(subScreen.updates.first.scenario, 'recognizing'); expect(subScreen.updates.first.continuousFaceRecognition, isTrue); await recognition.emitFaceAt(0, '1001'); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.remainingSeconds.value, 10); expect(subScreen.updates.last.scenario, 'success'); expect(subScreen.updates.last.continuousFaceRecognition, isTrue); now = now.add(const Duration(seconds: 10)); await tester.pump(const Duration(seconds: 1)); await tester.pump(); expect(controller.result.value, isNull); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(recognition.faceCallbacks, hasLength(2)); expect(recognition.faceEmbeddedPreviews, [isTrue, isTrue]); expect(subScreen.clearCalls, 0); expect(subScreen.updates.last.scenario, 'recognizing'); expect(subScreen.updates.last.continuousFaceRecognition, isTrue); }); testWidgets('持续人脸主屏在 1920x1080 保留常驻摄像头且无布局异常', (tester) async { tester.view.physicalSize = const Size(1920, 1080); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('李*华', 1)), recognitionGateway: _FakeRecognitionGateway(), subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoFacePreferences, ); Get.testMode = true; Get.put(controller); addTearDown(() async { if (Get.isRegistered()) { await Get.delete(force: true); } Get.reset(); }); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(1920, 1080), builder: (_, __) => MaterialApp( home: FTheme( data: FThemes.zinc.light, child: const Scaffold(body: ReservationMenuQueryPage()), ), ), ), ); await tester.pump(); final preview = find.byKey( const ValueKey('reservation-live-face-preview'), ); expect(preview, findsOneWidget); final previewSize = tester.getSize(preview); expect(previewSize.width / previewSize.height, closeTo(16 / 9, 0.01)); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); await Get.delete(force: true); Get.reset(); }); testWidgets('自动人脸被取消后暂停且不循环弹窗,仍可人工重新识别', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('李*华', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoFacePreferences, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); expect(recognition.faceCallbacks, hasLength(1)); recognition.faceFailures.single( const ReservationRecognitionFailure( ReservationRecognitionFailureType.cancelled, '已取消人脸识别', ), ); await tester.pump(); expect(controller.status.value, ReservationMenuQueryStatus.identifyError); expect(controller.errorMessage.value, '已取消人脸识别'); expect(recognition.faceCallbacks, hasLength(1)); await controller.startRecognition(); expect(recognition.faceCallbacks, hasLength(2)); }); testWidgets('持续自动识别开启时饭卡正常等待超时会开启下一轮', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoCardPreferences, recognitionRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); expect(recognition.cardCallbacks, hasLength(1)); recognition.cardFailures.single( const ReservationRecognitionFailure( ReservationRecognitionFailureType.timeout, '等待饭卡超时', ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(controller.errorMessage.value, isEmpty); expect(recognition.cardCallbacks, hasLength(2)); }); testWidgets('持续自动识别开启时首次刷卡失败会提示并继续等待下一张卡', (tester) async { final recognition = _FakeRecognitionGateway(); final errors = []; final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoCardPreferences, errorNotifier: errors.add, recognitionFailureRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); recognition.cardFailures.single( const ReservationRecognitionFailure( ReservationRecognitionFailureType.identify, '饭卡读取失败,请重新刷卡', ), ); await tester.pump(); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(controller.errorMessage.value, isEmpty); expect(errors, ['饭卡读取失败,请重新刷卡']); await tester.pump(const Duration(milliseconds: 10)); expect(recognition.cardCallbacks, hasLength(2)); }); testWidgets('持续自动识别开启时首次未查询出菜单会提示并继续等待下一张卡', (tester) async { final recognition = _FakeRecognitionGateway(); final errors = []; final controller = ReservationMenuQueryController( repository: _SequenceRepository([null, _result('李*华', 1)]), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoCardPreferences, errorNotifier: errors.add, recognitionFailureRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await recognition.emitCardAt(0, 'CARD-001'); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(controller.result.value, isNull); expect(controller.errorMessage.value, isEmpty); expect(errors, ['未获取到预约查询结果']); await tester.pump(const Duration(milliseconds: 10)); expect(recognition.cardCallbacks, hasLength(2)); await recognition.emitCardAt(1, 'CARD-002'); expect(controller.status.value, ReservationMenuQueryStatus.success); expect(controller.result.value?.maskedName, '李*华'); await controller.clearForPrivacy(); }); testWidgets('持续自动识别关闭时饭卡等待超时暂停并保留人工重试', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoCardSingleRoundPreferences, recognitionRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); recognition.cardFailures.single( const ReservationRecognitionFailure( ReservationRecognitionFailureType.timeout, '等待饭卡超时', ), ); await tester.pump(const Duration(milliseconds: 30)); expect(controller.continuousRecognitionEnabled.value, isFalse); expect(controller.status.value, ReservationMenuQueryStatus.identifyError); expect(controller.errorMessage.value, '等待饭卡超时'); expect(recognition.cardCallbacks, hasLength(1)); }); testWidgets('持续自动识别不会对设备故障无限重试', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoCardPreferences, recognitionRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); recognition.cardFailures.single( const ReservationRecognitionFailure( ReservationRecognitionFailureType.device, '读卡器未连接,请检查设备后重试', ), ); await tester.pump(const Duration(milliseconds: 30)); expect(controller.status.value, ReservationMenuQueryStatus.deviceError); expect(recognition.cardCallbacks, hasLength(1)); }); testWidgets('人脸会话未来上报正常超时时复用持续识别续轮策略', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('李*华', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoFacePreferences, recognitionRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); recognition.faceFailures.single( const ReservationRecognitionFailure( ReservationRecognitionFailureType.timeout, '等待人脸超时', ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 10)); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(recognition.faceCallbacks, hasLength(2)); }); testWidgets('切换识别方式会取消旧饭卡超时续轮任务', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, preferencesLoader: _autoCardPreferences, recognitionRestartDelay: const Duration(seconds: 1), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); recognition.cardFailures.single( const ReservationRecognitionFailure( ReservationRecognitionFailureType.timeout, '等待饭卡超时', ), ); await tester.pump(); await controller.selectMethod(ReservationRecognitionMethod.face); await tester.pump(const Duration(seconds: 2)); expect(controller.method.value, ReservationRecognitionMethod.face); expect(recognition.cardCallbacks, hasLength(1)); expect(recognition.faceCallbacks, hasLength(1)); }); testWidgets('重复识别只查询一次,旧响应不能覆盖新身份', (tester) async { final repository = _CompleterRepository(); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _defaultPreferences, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); expect(recognition.cardCallbacks, hasLength(1)); final first = recognition.emitCardAt(0, 'old-card'); final duplicate = recognition.emitCardAt(0, 'old-card'); await tester.pump(); expect(repository.calls, hasLength(1)); await duplicate; await controller.startRecognition(); expect(recognition.cardCallbacks, hasLength(2)); final second = recognition.emitCardAt(1, 'new-card'); await tester.pump(); expect(repository.calls, hasLength(2)); repository.completers[1].complete(_result('李*华', 1)); await second; expect(controller.result.value?.maskedName, '李*华'); repository.completers[0].complete(_result('张*明', 1)); await first; expect(controller.result.value?.maskedName, '李*华'); expect(controller.status.value, ReservationMenuQueryStatus.success); await controller.clearForPrivacy(); }); testWidgets('11 天本地分页、翻页续时并在到期后主副屏清空', (tester) async { var now = DateTime(2026, 7, 24, 8); final repository = _ImmediateRepository(_result('张*明', 11)); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, privacyDuration: const Duration(seconds: 3), now: () => now, preferencesLoader: _defaultPreferences, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitCardAt(0, 'card-1'); expect(controller.totalPages, 2); expect(controller.currentDays, hasLength(10)); expect(controller.remainingSeconds.value, 3); now = now.add(const Duration(seconds: 2)); await tester.pump(const Duration(seconds: 1)); expect(controller.remainingSeconds.value, 1); await controller.changePage(2); expect(controller.currentDays, hasLength(1)); expect(controller.remainingSeconds.value, 3); expect(subScreen.updates.last.currentPage, 2); expect(subScreen.updates.last.days, hasLength(1)); now = now.add(const Duration(seconds: 3)); await tester.pump(const Duration(seconds: 1)); await tester.pump(); expect(controller.status.value, ReservationMenuQueryStatus.expired); expect(controller.result.value, isNull); expect(subScreen.clearCalls, 1); }); testWidgets('断网时不调用预约接口,单屏不可用不影响主屏查询', (tester) async { final offlineRepository = _ImmediateRepository(_result('张*明', 1)); final offlineRecognition = _FakeRecognitionGateway(); final offlineController = ReservationMenuQueryController( repository: offlineRepository, recognitionGateway: offlineRecognition, subScreenGateway: _FakeSubScreenGateway(available: false), networkAvailable: () => false, preferencesLoader: _defaultPreferences, ); addTearDown(offlineController.onClose); offlineController.onInit(); await tester.pump(); await offlineController.startRecognition(); await offlineRecognition.emitCardAt(0, 'card-offline'); expect(offlineRepository.calls, isEmpty); expect(offlineController.status.value, ReservationMenuQueryStatus.networkError); final onlineRepository = _ImmediateRepository(_result('李*华', 1)); final onlineRecognition = _FakeRecognitionGateway(); final onlineController = ReservationMenuQueryController( repository: onlineRepository, recognitionGateway: onlineRecognition, subScreenGateway: _FakeSubScreenGateway(available: false), networkAvailable: () => true, preferencesLoader: _defaultPreferences, ); addTearDown(onlineController.onClose); onlineController.onInit(); await tester.pump(); await onlineController.startRecognition(); await onlineRecognition.emitCardAt(0, 'card-online'); expect(onlineController.status.value, ReservationMenuQueryStatus.success); expect(onlineController.result.value?.maskedName, '李*华'); await onlineController.clearForPrivacy(); }); testWidgets('人脸方式先显示识别页,识别成功后才发送预约查询状态', (tester) async { final repository = _ImmediateRepository(_result('张*明', 1)); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _defaultPreferences, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); subScreen.updates.clear(); await controller.selectMethod(ReservationRecognitionMethod.face); expect(recognition.faceCallbacks, isEmpty); expect(controller.status.value, ReservationMenuQueryStatus.idle); expect( subScreen.updates.map((event) => event.scenario), ['idle'], reason: '切换查询方式只同步等待态,不能提前进入识别或预约查询 loading', ); subScreen.updates.clear(); await controller.startRecognition(); expect(recognition.faceCallbacks, hasLength(1)); expect( subScreen.updates, isEmpty, reason: '人脸识别开始后由统一人脸协议接管副屏,不能提前发送预约 loading', ); await recognition.emitFaceAt(0, '1001'); expect(repository.calls, [ const _QueryCall(identityValue: '1001', maskName: true), ]); expect( subScreen.updates.map((event) => event.scenario), ['loading', 'success'], ); await controller.clearForPrivacy(); }); testWidgets('副屏状态不回包也不能阻塞预约接口', (tester) async { final repository = _ImmediateRepository(_result('张*明', 1)); final recognition = _FakeRecognitionGateway(); final subScreen = _HangingSubScreenGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, preferencesLoader: _defaultPreferences, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); final recognitionFuture = recognition.emitCardAt(0, 'card-1'); await tester.pump(); expect(repository.calls, [ const _QueryCall(identityValue: 'card-1', maskName: true), ]); subScreen.releaseUpdates(); await recognitionFuture; expect(controller.status.value, ReservationMenuQueryStatus.success); await controller.clearForPrivacy(); }); testWidgets('饭卡核销提交点击餐段中勾选的多个订单,成功后刷新并播报', (tester) async { final repository = _SequenceRepository([ _resultWithOrderIds('张*明', const [1001, 1002]), _result('张*明', 0), ]); final recognition = _FakeRecognitionGateway(); final verification = _FakeVerificationGateway(); final spoken = []; final successes = []; final errors = []; final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), verificationGateway: verification, networkAvailable: () => true, preferencesLoader: _verificationPreferences, speaker: (message) async => spoken.add(message), successNotifier: successes.add, errorNotifier: errors.add, now: () => DateTime(2026, 7, 24, 8), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitCardAt(0, 'CARD-001'); final day = controller.result.value!.days.single; final meal = day.meals.single; expect( await controller.confirmVerification(day, meal, const [1001, 1002]), isTrue, ); expect( verification.calls, const [ _VerificationCall( orderIds: [1001, 1002], ), ], ); expect(repository.calls, [ const _QueryCall(identityValue: 'CARD-001', maskName: true), const _QueryCall(identityValue: 'CARD-001', maskName: true), ]); expect(controller.status.value, ReservationMenuQueryStatus.empty); expect(successes, ['已核销 2 笔预约,页面已刷新']); expect(errors, isEmpty); await tester.pump(); expect(spoken, ['2笔预约核销成功']); await controller.clearForPrivacy(); }); testWidgets('多笔预约部分核销成功时刷新结果并明确提示成功与失败数量', (tester) async { final repository = _SequenceRepository([ _resultWithOrderIds('张*明', const [1001, 1002]), _resultWithOrderIds('张*明', const [1002]), ]); final recognition = _FakeRecognitionGateway(); final verification = _FakeVerificationGateway( result: const ReservationVerificationResult( succeededOrderIds: [1001], failedOrderIds: [1002], ), ); final spoken = []; final successes = []; final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), verificationGateway: verification, networkAvailable: () => true, preferencesLoader: _verificationPreferences, speaker: (message) async => spoken.add(message), successNotifier: successes.add, now: () => DateTime(2026, 7, 24, 8), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitCardAt(0, 'CARD-001'); final day = controller.result.value!.days.single; final meal = day.meals.single; expect( await controller.confirmVerification(day, meal, const [1001, 1002]), isTrue, ); expect(controller.result.value!.days.single.meals.single.orderIds, [1002]); expect(successes, ['已核销 1 笔预约,1 笔未成功,页面已刷新']); await tester.pump(); expect(spoken, ['1笔预约核销成功,1笔未成功']); await controller.clearForPrivacy(); }); testWidgets('人脸核销把识别证据会话绑定到所选订单且成功后不再清理', (tester) async { final repository = _SequenceRepository([ _resultWithOrderIds('张*明', const [1002]), _result('张*明', 0), ]); final recognition = _FakeRecognitionGateway(); final verification = _FakeVerificationGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), verificationGateway: verification, networkAvailable: () => true, speaker: (_) async {}, successNotifier: (_) {}, errorNotifier: (_) {}, preferencesLoader: () async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.face, verificationEnabled: true, verificationDateScope: ReservationVerificationDateScope.all, ), now: () => DateTime(2026, 7, 24, 8), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitFaceAt( 0, '1001', evidenceSessionId: 'face-session-1', ); final day = controller.result.value!.days.single; final meal = day.meals.single; expect( await controller.confirmVerification(day, meal, const [1002]), isTrue); expect( verification.calls, const [ _VerificationCall( orderIds: [1002], faceEvidenceSessionId: 'face-session-1', ), ], ); await controller.clearForPrivacy(); expect(verification.discardedSessions, isEmpty); }); testWidgets('人脸仅查询未核销时在隐私清屏中删除现场图片任务', (tester) async { final recognition = _FakeRecognitionGateway(); final verification = _FakeVerificationGateway(); final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_result('张*明', 1)), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), verificationGateway: verification, networkAvailable: () => true, preferencesLoader: () async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.face, ), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitFaceAt( 0, '1001', evidenceSessionId: 'face-session-unused', ); await controller.clearForPrivacy(); expect(verification.discardedSessions, ['face-session-unused']); }); testWidgets('点击午餐时传入早餐订单 ID 必须拒绝且不能调用核销接口', (tester) async { final recognition = _FakeRecognitionGateway(); final verification = _FakeVerificationGateway(); final errors = []; final controller = ReservationMenuQueryController( repository: _ImmediateRepository(_resultWithBreakfastAndNoon()), recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), verificationGateway: verification, networkAvailable: () => true, preferencesLoader: _verificationPreferences, errorNotifier: errors.add, now: () => DateTime(2026, 7, 24, 8), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitCardAt(0, 'CARD-001'); final day = controller.result.value!.days.single; final noonMeal = day.meals.singleWhere( ReservationMealSlot.noon.matches, ); expect( await controller.confirmVerification(day, noonMeal, const [2001]), isFalse, ); expect(verification.calls, isEmpty); expect(errors, ['部分所选预约订单已失效,请重新查询']); await controller.clearForPrivacy(); }); testWidgets('核销已成功但刷新失败时不误报失败,并清除旧结果', (tester) async { final repository = _SequenceRepository([ _result('张*明', 1), null, ]); final recognition = _FakeRecognitionGateway(); final verification = _FakeVerificationGateway(); final spoken = []; final successes = []; final errors = []; final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), verificationGateway: verification, networkAvailable: () => true, preferencesLoader: _verificationPreferences, speaker: (message) async => spoken.add(message), successNotifier: successes.add, errorNotifier: errors.add, now: () => DateTime(2026, 7, 24, 8), ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); await controller.startRecognition(); await recognition.emitCardAt(0, 'CARD-001'); final day = controller.result.value!.days.single; final meal = day.meals.single; expect( await controller.confirmVerification(day, meal, meal.orderIds), isTrue, ); expect(successes, ['核销成功,请重新识别刷新结果']); expect(errors, isEmpty); expect(controller.result.value, isNull); expect(controller.status.value, ReservationMenuQueryStatus.idle); await tester.pump(); expect(spoken, ['核销成功']); }); test('预约日期模型按 5、10、11 天正确分页', () { for (final entry in const {5: 1, 10: 1, 11: 2}.entries) { final display = ReservationMenuDisplayData.fromService( _result('张*明', entry.key), ); expect(display.totalPages, entry.value); expect(display.pageDays(1), hasLength(entry.key.clamp(0, 10))); if (entry.key == 11) expect(display.pageDays(2), hasLength(1)); } }); test('后端字符串或数字订单 ID 正确解析,副屏消息不携带核销标识', () { final serviceMeal = ServiceReservationMeal.fromJson({ 'hourType': '2', 'mealTitle': '午餐', 'items': ['香菇滑鸡'], 'orderIds': ['2080112524665057281', 2081674128087121921], 'orders': [ { 'orderId': '2080112524665057281', 'items': [ { 'productId': '2085184283437215745', 'productName': '香菇滑鸡', 'productPic': '/menu/chicken.jpg', 'productCount': 2, 'spuType': 2, 'comboItems': [ { 'childSpuId': '2082059339085946881', 'name': '小青菜', 'picUrl': '/menu/vegetable.jpg', 'quantity': 1, 'sortOrder': 0, 'price': 200, }, ], }, ], }, ], }); final meal = ReservationMenuMealData.fromService(serviceMeal); final day = ReservationMenuDayData(date: '2026-07-24', meals: [meal]); expect(meal.orderIds, [2080112524665057281, 2081674128087121921]); expect(meal.orders.single.orderId, 2080112524665057281); expect(meal.orders.single.items.single.productName, '香菇滑鸡'); expect(meal.orders.single.items.single.productId, 2085184283437215745); expect(meal.orders.single.items.single.productPic, '/menu/chicken.jpg'); expect(meal.orders.single.items.single.productCount, 2); expect(meal.orders.single.items.single.spuType, 2); expect(meal.orders.single.items.single.comboItems.single.name, '小青菜'); expect(meal.orders.single.items.single.comboItems.single.quantity, 1); final serializedOrder = (serviceMeal.toJson()['orders'] as List) .single as Map; expect(serializedOrder['items'], [ { 'productId': 2085184283437215745, 'productName': '香菇滑鸡', 'productPic': '/menu/chicken.jpg', 'productCount': 2, 'spuType': 2, 'comboItems': [ { 'childSpuId': 2082059339085946881, 'name': '小青菜', 'picUrl': '/menu/vegetable.jpg', 'quantity': 1, 'sortOrder': 0, 'price': 200, }, ], }, ]); expect(day.toJson().toString(), isNot(contains('orderIds'))); expect(day.toJson().toString(), isNot(contains('orders'))); }); test('预约查询请求使用生成式 JSON 序列化并省略无关身份字段', () { const cardRequest = ReservationMenuQueryRequest( hardId: 100, recognitionType: 'CARD', maskName: true, cardNumber: 'CARD-001', ); expect(cardRequest.toJson(), { 'hardId': 100, 'recognitionType': 'CARD', 'maskName': true, 'cardNumber': 'CARD-001', }); final restored = ReservationMenuQueryRequest.fromJson({ 'hardId': 100, 'recognitionType': 'FACE', 'maskName': false, 'faceUserId': 300, }); expect(restored.hardId, 100); expect(restored.recognitionType, 'FACE'); expect(restored.maskName, isFalse); expect(restored.cardNumber, isNull); expect(restored.faceUserId, 300); }); test('后端只返回午餐时仅补齐基础三餐,不生成夜间餐占位', () { final day = ReservationMenuDisplayData.fromService( _result('张*明', 1), ).days.single; expect( day.displayMeals.map((meal) => meal.title), ['早餐', '午餐', '晚餐'], ); expect( day.displayMeals.map((meal) => meal.reserved), [false, true, false], ); expect(day.displayMeals[1].hourType, '2'); }); test('后端返回夜间餐时追加展示并保留餐品数据', () { const day = ReservationMenuDayData( date: '2026-07-24', meals: [ ReservationMenuMealData( hourType: 'NOONING', title: '午餐', items: ['香菇滑鸡'], ), ReservationMenuMealData( hourType: 'LATE_NIGHT', title: '夜间餐', items: ['牛肉面'], ), ], ); expect( day.displayMeals.map((meal) => meal.title), ['早餐', '午餐', '晚餐', '夜间餐'], ); expect(day.displayMeals.last.reserved, isTrue); expect(day.displayMeals.last.items, ['牛肉面']); }); } Future _defaultPreferences() async => ReservationQueryPagePreferences.defaults; Future _autoCardPreferences() async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, autoRecognitionEnabled: true, ); Future _autoFacePreferences() async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.face, autoRecognitionEnabled: true, ); Future _autoCardSingleRoundPreferences() async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, autoRecognitionEnabled: true, continuousRecognitionEnabled: false, ); Future _verificationPreferences() async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, verificationEnabled: true, verificationDateScope: ReservationVerificationDateScope.all, ); /// 构建开启快速核销的测试偏好,保持生产默认值不被测试场景污染。 Future _quickVerificationPreferences() async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, verificationEnabled: true, quickVerificationEnabled: true, verificationDateScope: ReservationVerificationDateScope.all, ); class _CompleterRepository implements ReservationMenuRepository { final calls = <_QueryCall>[]; final completers = >[]; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, required bool maskName, }) { calls.add(_QueryCall(identityValue: identityValue, maskName: maskName)); final completer = Completer(); completers.add(completer); return completer.future; } } class _ImmediateRepository implements ReservationMenuRepository { _ImmediateRepository(this.value); final ServiceReservationMenuQuery value; final calls = <_QueryCall>[]; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, required bool maskName, }) async { calls.add(_QueryCall(identityValue: identityValue, maskName: maskName)); return value; } } class _SequenceRepository implements ReservationMenuRepository { _SequenceRepository(this.responses); final List responses; final calls = <_QueryCall>[]; int _index = 0; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, required bool maskName, }) async { calls.add(_QueryCall(identityValue: identityValue, maskName: maskName)); return responses[_index++]; } } class _QueryCall { const _QueryCall({required this.identityValue, required this.maskName}); final String identityValue; final bool maskName; @override bool operator ==(Object other) => other is _QueryCall && other.identityValue == identityValue && other.maskName == maskName; @override int get hashCode => Object.hash(identityValue, maskName); @override String toString() => '_QueryCall(identityValue: $identityValue, maskName: $maskName)'; } /// 记录 Controller 批量核销调用并默认把全部传入订单标记为成功的测试网关。 class _FakeVerificationGateway implements ReservationVerificationGateway { /// 创建默认全成功或返回指定逐笔结果的测试网关。 _FakeVerificationGateway({this.result}); /// 已收到的逐次批量核销调用。 final calls = <_VerificationCall>[]; /// Controller 要求清理的未使用人脸证据会话。 final discardedSessions = []; /// 测试指定的逐笔结果;为空时根据传入订单构造全部成功。 final ReservationVerificationResult? result; /// 记录订单列表与人脸会话,并模拟所选订单全部核销成功。 @override Future cancel({ required List orderIds, String? faceEvidenceSessionId, }) async { calls.add( _VerificationCall( orderIds: orderIds, faceEvidenceSessionId: faceEvidenceSessionId, ), ); return result ?? ReservationVerificationResult( succeededOrderIds: List.unmodifiable(orderIds), failedOrderIds: const [], ); } /// 记录 Controller 放弃的未绑定人脸证据会话。 @override Future discardFaceEvidence({ required String recognitionSessionId, required String reason, }) async { discardedSessions.add(recognitionSessionId); } } /// 一次批量核销调用的不可变断言快照。 class _VerificationCall { /// 创建包含订单顺序和可选人脸证据会话的调用快照。 const _VerificationCall({ required this.orderIds, this.faceEvidenceSessionId, }); /// Controller 按当前餐段顺序提交的订单 ID。 final List orderIds; /// 人脸识别产生的证据会话;饭卡核销时为空。 final String? faceEvidenceSessionId; /// 按订单顺序和证据会话比较两次核销调用。 @override bool operator ==(Object other) => other is _VerificationCall && listEquals(other.orderIds, orderIds) && other.faceEvidenceSessionId == faceEvidenceSessionId; /// 生成与相等判断字段一致的哈希值。 @override int get hashCode => Object.hash(Object.hashAll(orderIds), faceEvidenceSessionId); } class _FakeRecognitionGateway implements ReservationRecognitionGateway { final cardCallbacks = Function(ReservationRecognizedIdentity)>[]; final cardFailures = []; final faceCallbacks = Function(ReservationRecognizedIdentity)>[]; final faceFailures = []; /// 每次人脸启动是否使用页面内嵌预览,用于校验持续模式不会再弹独立识别框。 final faceEmbeddedPreviews = []; int stopAllCalls = 0; Future emitCardAt(int index, String value) { return cardCallbacks[index](ReservationRecognizedIdentity( identityValue: value, )); } Future emitFaceAt( int index, String value, { String? evidenceSessionId, }) { return faceCallbacks[index](ReservationRecognizedIdentity( identityValue: value, faceEvidenceSessionId: evidenceSessionId, )); } @override Future startCard({ required Future Function(ReservationRecognizedIdentity identity) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, }) async { cardCallbacks.add(onRecognized); cardFailures.add(onFailure); } @override Future startFace({ required Future Function(ReservationRecognizedIdentity identity) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, bool embeddedPreview = false, }) async { faceCallbacks.add(onRecognized); faceFailures.add(onFailure); faceEmbeddedPreviews.add(embeddedPreview); } @override Future stopAll() async { stopAllCalls++; } @override Future stopCard() async {} @override Future stopFace() async {} } class _FakeSubScreenGateway implements ReservationSubScreenGateway { _FakeSubScreenGateway({this.available = true}); final bool available; final updates = []; int showCalls = 0; int clearCalls = 0; @override bool get isAvailable => available; @override Future show() async { showCalls++; } @override Future update(SubScreenReservationQueryEvent event) async { updates.add(event); } @override Future clear({ required String sessionId, required int revision, }) async { clearCalls++; } } class _HangingSubScreenGateway implements ReservationSubScreenGateway { final _pendingUpdates = >[]; @override bool get isAvailable => true; @override Future show() async {} @override Future update(SubScreenReservationQueryEvent event) { final completer = Completer(); _pendingUpdates.add(completer); return completer.future; } @override Future clear( {required String sessionId, required int revision}) async {} void releaseUpdates() { for (final completer in _pendingUpdates) { if (!completer.isCompleted) completer.complete(); } } } ServiceReservationMenuQuery _result(String maskedName, int dayCount) { final start = DateTime(2026, 7, 24); final days = List.generate(dayCount, (index) { final date = start.add(Duration(days: index)); return ServiceReservationDay( date: '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}', meals: [ ServiceReservationMeal( hourType: 'LUNCH', mealTitle: '午餐', items: const ['香菇滑鸡', '清炒时蔬'], orderIds: [1001 + index], ), ], ); }); return ServiceReservationMenuQuery( maskedName: maskedName, deptName: '三年级二班', merchantName: '第一食堂', totalDayCount: dayCount, totalMealCount: dayCount, days: days, ); } ServiceReservationMenuQuery _resultWithOrderIds( String maskedName, List orderIds, ) { return ServiceReservationMenuQuery( maskedName: maskedName, deptName: '三年级二班', merchantName: '第一食堂', totalDayCount: 1, totalMealCount: orderIds.length, days: [ ServiceReservationDay( date: '2026-07-24', meals: [ ServiceReservationMeal( hourType: '2', mealTitle: '午餐', items: const ['香菇滑鸡', '清炒时蔬'], orderIds: orderIds, ), ], ), ], ); } ServiceReservationMenuQuery _resultWithBreakfastAndNoon() { return ServiceReservationMenuQuery( maskedName: '张*明', deptName: '三年级二班', merchantName: '第一食堂', totalDayCount: 1, totalMealCount: 2, days: const [ ServiceReservationDay( date: '2026-07-24', meals: [ ServiceReservationMeal( hourType: '1', mealTitle: '早餐', items: ['包子'], orderIds: [2001], ), ServiceReservationMeal( hourType: '2', mealTitle: '午餐', items: ['香菇滑鸡'], orderIds: [2002], ), ], ), ], ); }