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'; void main() { testWidgets('重复识别只查询一次,旧响应不能覆盖新身份', (tester) async { final repository = _CompleterRepository(); final recognition = _FakeRecognitionGateway(); final subScreen = _FakeSubScreenGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, subScreenGateway: subScreen, networkAvailable: () => true, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); 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, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); 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, ); addTearDown(offlineController.onClose); offlineController.onInit(); await tester.pump(); 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, ); addTearDown(onlineController.onClose); onlineController.onInit(); await tester.pump(); 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, ); addTearDown(controller.onClose); controller.onInit(); await tester.pump(); subScreen.updates.clear(); await controller.selectMethod(ReservationRecognitionMethod.face); expect(recognition.faceCallbacks, hasLength(1)); expect( subScreen.updates, isEmpty, reason: '人脸识别开始后由统一人脸协议接管副屏,不能提前发送预约 loading', ); await recognition.emitFaceAt(0, '1001'); expect(repository.calls, ['1001']); expect( subScreen.updates.map((event) => event.scenario), ['loading', 'success'], ); await controller.clearForPrivacy(); }); 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)); } }); } class _CompleterRepository implements ReservationMenuRepository { final calls = []; final completers = >[]; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, }) { calls.add(identityValue); final completer = Completer(); completers.add(completer); return completer.future; } } class _ImmediateRepository implements ReservationMenuRepository { _ImmediateRepository(this.value); final ServiceReservationMenuQuery value; final calls = []; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, }) async { calls.add(identityValue); return value; } } class _FakeRecognitionGateway implements ReservationRecognitionGateway { final cardCallbacks = Function(String)>[]; final cardFailures = []; final faceCallbacks = Function(String)>[]; final faceFailures = []; int stopAllCalls = 0; Future emitCardAt(int index, String value) { return cardCallbacks[index](value); } Future emitFaceAt(int index, String value) { return faceCallbacks[index](value); } @override Future startCard({ required Future Function(String cardNumber) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, }) async { cardCallbacks.add(onRecognized); cardFailures.add(onFailure); } @override Future startFace({ required Future Function(String userId) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, }) async { faceCallbacks.add(onRecognized); faceFailures.add(onFailure); } @override Future stopAll() async { stopAllCalls++; } @override Future stopCard() async {} @override Future stopFace() async {} } class _FakeSubScreenGateway implements ReservationSubScreenGateway { _FakeSubScreenGateway({this.available = true}); final bool available; final updates = []; int showCalls = 0; int clearCalls = 0; @override bool get isAvailable => available; @override Future show() async { showCalls++; } @override Future update(SubScreenReservationQueryEvent event) async { updates.add(event); } @override Future clear({ required String sessionId, required int revision, }) async { clearCalls++; } } ServiceReservationMenuQuery _result(String maskedName, int dayCount) { final start = DateTime(2026, 7, 24); final days = List.generate(dayCount, (index) { final date = start.add(Duration(days: index)); return ServiceReservationDay( date: '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}', meals: const [ ServiceReservationMeal( hourType: 'LUNCH', mealTitle: '午餐', items: ['香菇滑鸡', '清炒时蔬'], ), ], ); }); return ServiceReservationMenuQuery( maskedName: maskedName, deptName: '三年级二班', merchantName: '第一食堂', totalDayCount: dayCount, totalMealCount: dayCount, days: days, ); }