912 lines
30 KiB
Dart
912 lines
30 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.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';
|
|
|
|
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,
|
|
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.nameMaskEnabled.value, isFalse);
|
|
expect(controller.status.value, ReservationMenuQueryStatus.idle);
|
|
expect(controller.verificationEnabled.value, isTrue);
|
|
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 {
|
|
var now = DateTime(2026, 8, 4, 8);
|
|
final repository = _ImmediateRepository(_result('张*明', 1));
|
|
final recognition = _FakeRecognitionGateway();
|
|
final controller = ReservationMenuQueryController(
|
|
repository: repository,
|
|
recognitionGateway: recognition,
|
|
subScreenGateway: _FakeSubScreenGateway(),
|
|
networkAvailable: () => true,
|
|
privacyDuration: const Duration(seconds: 2),
|
|
now: () => now,
|
|
preferencesLoader: _autoCardPreferences,
|
|
);
|
|
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);
|
|
|
|
now = now.add(const Duration(seconds: 2));
|
|
await tester.pump(const Duration(seconds: 1));
|
|
await tester.pump();
|
|
|
|
expect(controller.result.value, isNull);
|
|
expect(controller.status.value, ReservationMenuQueryStatus.recognizing);
|
|
expect(recognition.cardCallbacks, hasLength(2));
|
|
});
|
|
|
|
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));
|
|
|
|
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 {
|
|
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 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('饭卡核销只提交点击餐段中选中的订单 ID,成功后刷新并播报', (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, 1002), isTrue);
|
|
expect(
|
|
verification.calls,
|
|
const [
|
|
_VerificationCall(
|
|
orderId: 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, ['核销成功,页面已刷新']);
|
|
expect(errors, isEmpty);
|
|
await tester.pump();
|
|
expect(spoken, ['核销成功']);
|
|
await controller.clearForPrivacy();
|
|
});
|
|
|
|
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, 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.single),
|
|
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': [
|
|
{
|
|
'productName': '香菇滑鸡',
|
|
'productPic': '/menu/chicken.jpg',
|
|
'productCount': 2,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
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.productPic, '/menu/chicken.jpg');
|
|
expect(meal.orders.single.items.single.productCount, 2);
|
|
final serializedOrder = (serviceMeal.toJson()['orders'] as List<dynamic>)
|
|
.single as Map<String, dynamic>;
|
|
expect(serializedOrder['items'], [
|
|
{
|
|
'productName': '香菇滑鸡',
|
|
'productPic': '/menu/chicken.jpg',
|
|
'productCount': 2,
|
|
},
|
|
]);
|
|
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> _verificationPreferences() async =>
|
|
const ReservationQueryPagePreferences(
|
|
methodOrder: ReservationRecognitionMethod.values,
|
|
defaultMethod: ReservationRecognitionMethod.card,
|
|
verificationEnabled: 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)';
|
|
}
|
|
|
|
class _FakeVerificationGateway implements ReservationVerificationGateway {
|
|
final calls = <_VerificationCall>[];
|
|
|
|
@override
|
|
Future<bool> cancel({
|
|
required int orderId,
|
|
}) async {
|
|
calls.add(
|
|
_VerificationCall(
|
|
orderId: orderId,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class _VerificationCall {
|
|
const _VerificationCall({
|
|
required this.orderId,
|
|
});
|
|
|
|
final int orderId;
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is _VerificationCall && other.orderId == orderId;
|
|
|
|
@override
|
|
int get hashCode => orderId.hashCode;
|
|
}
|
|
|
|
class _FakeRecognitionGateway implements ReservationRecognitionGateway {
|
|
final cardCallbacks = <Future<void> Function(String)>[];
|
|
final cardFailures = <void Function(ReservationRecognitionFailure failure)>[];
|
|
final faceCallbacks = <Future<void> Function(String)>[];
|
|
final faceFailures = <void Function(ReservationRecognitionFailure failure)>[];
|
|
int stopAllCalls = 0;
|
|
|
|
Future<void> emitCardAt(int index, String value) {
|
|
return cardCallbacks[index](value);
|
|
}
|
|
|
|
Future<void> emitFaceAt(int index, String value) {
|
|
return faceCallbacks[index](value);
|
|
}
|
|
|
|
@override
|
|
Future<void> startCard({
|
|
required Future<void> Function(String cardNumber) onRecognized,
|
|
required void Function(ReservationRecognitionFailure failure) onFailure,
|
|
}) async {
|
|
cardCallbacks.add(onRecognized);
|
|
cardFailures.add(onFailure);
|
|
}
|
|
|
|
@override
|
|
Future<void> startFace({
|
|
required Future<void> Function(String userId) onRecognized,
|
|
required void Function(ReservationRecognitionFailure failure) onFailure,
|
|
}) async {
|
|
faceCallbacks.add(onRecognized);
|
|
faceFailures.add(onFailure);
|
|
}
|
|
|
|
@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],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|