Files
biz_xfj_cloud/test/pages/terminal/menu_query_controller_test.dart
T
dzc bdc33b822f feat:核销放大
feat:锁屏页面
feat:饭卡自动查询预约
2026-08-04 19:35:06 +08:00

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],
),
],
),
],
);
}