Files
biz_xfj_cloud/test/pages/terminal/menu_query_controller_test.dart
T
dzc 9cc99deed7 feat:同时核销几笔订单
feat:人脸质量检测
2026-08-31 17:59:03 +08:00

1784 lines
63 KiB
Dart

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 = <ReservationMealSlot>[];
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 = <ReservationQuickVerificationRequest>[];
final errors = <String>[];
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 = <ReservationQuickVerificationRequest>[];
final errors = <String>[];
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 = <String>[];
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 = <String>[];
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 = <String>[];
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<ReservationMenuQueryController>()) {
await Get.delete<ReservationMenuQueryController>(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<ReservationMenuQueryController>(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 = <String>[];
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 = <String>[];
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 = <String>[];
final successes = <String>[];
final errors = <String>[];
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 = <String>[];
final successes = <String>[];
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 = <String>[];
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 = <String>[];
final successes = <String>[];
final errors = <String>[];
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<dynamic>)
.single as Map<String, dynamic>;
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<ReservationQueryPagePreferences> _defaultPreferences() async =>
ReservationQueryPagePreferences.defaults;
Future<ReservationQueryPagePreferences> _autoCardPreferences() async =>
const ReservationQueryPagePreferences(
methodOrder: ReservationRecognitionMethod.values,
defaultMethod: ReservationRecognitionMethod.card,
autoRecognitionEnabled: true,
);
Future<ReservationQueryPagePreferences> _autoFacePreferences() async =>
const ReservationQueryPagePreferences(
methodOrder: ReservationRecognitionMethod.values,
defaultMethod: ReservationRecognitionMethod.face,
autoRecognitionEnabled: true,
);
Future<ReservationQueryPagePreferences>
_autoCardSingleRoundPreferences() async =>
const ReservationQueryPagePreferences(
methodOrder: ReservationRecognitionMethod.values,
defaultMethod: ReservationRecognitionMethod.card,
autoRecognitionEnabled: true,
continuousRecognitionEnabled: false,
);
Future<ReservationQueryPagePreferences> _verificationPreferences() async =>
const ReservationQueryPagePreferences(
methodOrder: ReservationRecognitionMethod.values,
defaultMethod: ReservationRecognitionMethod.card,
verificationEnabled: true,
verificationDateScope: ReservationVerificationDateScope.all,
);
/// 构建开启快速核销的测试偏好,保持生产默认值不被测试场景污染。
Future<ReservationQueryPagePreferences> _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 = <Completer<ServiceReservationMenuQuery?>>[];
@override
Future<ServiceReservationMenuQuery?> query({
required ReservationRecognitionMethod method,
required String identityValue,
required bool maskName,
}) {
calls.add(_QueryCall(identityValue: identityValue, maskName: maskName));
final completer = Completer<ServiceReservationMenuQuery?>();
completers.add(completer);
return completer.future;
}
}
class _ImmediateRepository implements ReservationMenuRepository {
_ImmediateRepository(this.value);
final ServiceReservationMenuQuery value;
final calls = <_QueryCall>[];
@override
Future<ServiceReservationMenuQuery?> 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<ServiceReservationMenuQuery?> responses;
final calls = <_QueryCall>[];
int _index = 0;
@override
Future<ServiceReservationMenuQuery?> 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 = <String>[];
/// 测试指定的逐笔结果;为空时根据传入订单构造全部成功。
final ReservationVerificationResult? result;
/// 记录订单列表与人脸会话,并模拟所选订单全部核销成功。
@override
Future<ReservationVerificationResult> cancel({
required List<int> orderIds,
String? faceEvidenceSessionId,
}) async {
calls.add(
_VerificationCall(
orderIds: orderIds,
faceEvidenceSessionId: faceEvidenceSessionId,
),
);
return result ??
ReservationVerificationResult(
succeededOrderIds: List.unmodifiable(orderIds),
failedOrderIds: const [],
);
}
/// 记录 Controller 放弃的未绑定人脸证据会话。
@override
Future<void> discardFaceEvidence({
required String recognitionSessionId,
required String reason,
}) async {
discardedSessions.add(recognitionSessionId);
}
}
/// 一次批量核销调用的不可变断言快照。
class _VerificationCall {
/// 创建包含订单顺序和可选人脸证据会话的调用快照。
const _VerificationCall({
required this.orderIds,
this.faceEvidenceSessionId,
});
/// Controller 按当前餐段顺序提交的订单 ID。
final List<int> 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 =
<Future<void> Function(ReservationRecognizedIdentity)>[];
final cardFailures = <void Function(ReservationRecognitionFailure failure)>[];
final faceCallbacks =
<Future<void> Function(ReservationRecognizedIdentity)>[];
final faceFailures = <void Function(ReservationRecognitionFailure failure)>[];
/// 每次人脸启动是否使用页面内嵌预览,用于校验持续模式不会再弹独立识别框。
final faceEmbeddedPreviews = <bool>[];
int stopAllCalls = 0;
Future<void> emitCardAt(int index, String value) {
return cardCallbacks[index](ReservationRecognizedIdentity(
identityValue: value,
));
}
Future<void> emitFaceAt(
int index,
String value, {
String? evidenceSessionId,
}) {
return faceCallbacks[index](ReservationRecognizedIdentity(
identityValue: value,
faceEvidenceSessionId: evidenceSessionId,
));
}
@override
Future<void> startCard({
required Future<void> Function(ReservationRecognizedIdentity identity)
onRecognized,
required void Function(ReservationRecognitionFailure failure) onFailure,
}) async {
cardCallbacks.add(onRecognized);
cardFailures.add(onFailure);
}
@override
Future<void> startFace({
required Future<void> 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<void> stopAll() async {
stopAllCalls++;
}
@override
Future<void> stopCard() async {}
@override
Future<void> stopFace() async {}
}
class _FakeSubScreenGateway implements ReservationSubScreenGateway {
_FakeSubScreenGateway({this.available = true});
final bool available;
final updates = <SubScreenReservationQueryEvent>[];
int showCalls = 0;
int clearCalls = 0;
@override
bool get isAvailable => available;
@override
Future<void> show() async {
showCalls++;
}
@override
Future<void> update(SubScreenReservationQueryEvent event) async {
updates.add(event);
}
@override
Future<void> clear({
required String sessionId,
required int revision,
}) async {
clearCalls++;
}
}
class _HangingSubScreenGateway implements ReservationSubScreenGateway {
final _pendingUpdates = <Completer<void>>[];
@override
bool get isAvailable => true;
@override
Future<void> show() async {}
@override
Future<void> update(SubScreenReservationQueryEvent event) {
final completer = Completer<void>();
_pendingUpdates.add(completer);
return completer.future;
}
@override
Future<void> 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<ServiceReservationDay>.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<int> 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],
),
],
),
],
);
}