import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/models/page/reservation_menu_page_models.dart'; import 'package:pj_supermarket_cloud/common/widgets/app/reservation_schedule_card.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/reservation_query_coordinator.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_display.dart'; void main() { testWidgets('午餐预约卡只补齐基础三餐,不生成夜间餐占位', (tester) async { ReservationMenuMealData? verifiedMeal; const day = ReservationMenuDayData( date: '2026-07-24', meals: [ ReservationMenuMealData( hourType: 'NOONING', title: '午餐', items: ['香菇滑鸡'], orderIds: [1001], ), ], ); await tester.pumpWidget( MaterialApp( home: FTheme( data: FThemes.zinc.light, child: Center( child: SizedBox( width: 420, height: 420, child: ReservationScheduleCard( day: day, verificationEnabled: true, onVerify: (meal) => verifiedMeal = meal, ), ), ), ), ), ); for (final title in const ['早餐', '午餐', '晚餐']) { expect(find.text(title), findsOneWidget); } expect(find.text('夜间餐'), findsNothing); expect(find.text('未预定'), findsNWidgets(2)); expect(find.text('核销'), findsOneWidget); final verificationVisual = tester.getSize( find.byKey(const ValueKey('reservation-verification-visual-2')), ); final verificationTarget = tester.getSize( find.byKey(const ValueKey('reservation-verification-target-2')), ); expect(verificationVisual.height, 36); expect(verificationTarget.height, 48); expect(verificationTarget.width, greaterThan(verificationVisual.width)); await tester.tap(find.text('核销')); expect(verifiedMeal?.title, '午餐'); }); testWidgets('后端返回夜间餐时正常追加展示', (tester) async { const day = ReservationMenuDayData( date: '2026-07-24', meals: [ ReservationMenuMealData( hourType: 'LATE_NIGHT', title: '夜间餐', items: ['牛肉面'], ), ], ); await tester.pumpWidget( MaterialApp( home: FTheme( data: FThemes.zinc.light, child: const Center( child: SizedBox( width: 420, height: 420, child: ReservationScheduleCard(day: day), ), ), ), ), ); expect(find.text('夜间餐'), findsOneWidget); expect(find.text('牛肉面'), findsOneWidget); final lateNightText = tester.renderObject( find.byKey(const ValueKey('reservation-meal-title-4')), ); expect(lateNightText.maxLines, 1); expect(lateNightText.didExceedMaxLines, isFalse); }); testWidgets('关闭核销配置时预约餐段仍显示查询详情入口', (tester) async { ReservationMenuMealData? selectedMeal; const day = ReservationMenuDayData( date: '2026-07-24', meals: [ ReservationMenuMealData( hourType: '2', title: '午餐', items: ['小青菜'], orderIds: [1001], ), ], ); await tester.pumpWidget( MaterialApp( home: FTheme( data: FThemes.zinc.light, child: Center( child: SizedBox( width: 420, height: 420, child: ReservationScheduleCard( day: day, onVerify: (meal) => selectedMeal = meal, ), ), ), ), ), ); expect(find.text('核销'), findsNothing); expect(find.text('查询详情'), findsOneWidget); await tester.tap(find.text('查询详情')); expect(selectedMeal?.title, '午餐'); }); testWidgets('窄卡片中的查询详情保持紧凑且菜名不再逐字换行', (tester) async { const day = ReservationMenuDayData( date: '2026-07-24', meals: [ ReservationMenuMealData( hourType: '2', title: '午餐', items: ['小青菜'], orderIds: [1001], ), ], ); await tester.pumpWidget( MaterialApp( home: FTheme( data: FThemes.zinc.light, child: const Center( child: SizedBox( width: 220, height: 420, child: ReservationScheduleCard( day: day, ), ), ), ), ), ); expect(tester.takeException(), isNull); final itemText = tester.renderObject( find.text('小青菜'), ); final detailVisual = tester.getSize( find.byKey(const ValueKey('reservation-verification-visual-2')), ); expect(itemText.maxLines, 1); expect(itemText.didExceedMaxLines, isFalse); expect(itemText.size.height, lessThan(24)); expect(detailVisual.width, lessThanOrEqualTo(70)); expect(detailVisual.height, 30); }); for (final size in [ const Size(1920, 1080), const Size(1280, 720), const Size(1024, 600), ]) { testWidgets('${size.width.toInt()}x${size.height.toInt()} 各预约状态无布局异常', (tester) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); for (final scenario in ReservationQueryDisplayScenario.values) { final coordinator = ReservationQueryCoordinator( initialState: ReservationQueryDisplayState.demo( scenario, now: DateTime(2026, 7, 24, 8), ), ); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, home: FTheme( data: FThemes.zinc.light, child: ReservationQueryDisplayPage(coordinator: coordinator), ), ), ); await tester.pump(); expect( find.byKey(ValueKey('reservation-scenario-${scenario.name}')), findsOneWidget, ); expect(tester.takeException(), isNull); if (scenario == ReservationQueryDisplayScenario.success) { expect(find.byType(ReservationScheduleCard), findsNWidgets(10)); expect(find.text('张*明 · 三年级二班'), findsOneWidget); expect(find.text('张小明'), findsNothing); expect(find.byKey(const ValueKey('reservation-page-indicator')), findsOneWidget); } await tester.pumpWidget(const SizedBox.shrink()); coordinator.dispose(); } }); } testWidgets('小尺寸副屏的文字保底缩放,主标题不随布局缩到不可读', (tester) async { tester.view.physicalSize = const Size(1024, 600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final coordinator = ReservationQueryCoordinator( initialState: ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.idle, now: DateTime(2026, 7, 24, 8), ), ); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, home: FTheme( data: FThemes.zinc.light, child: ReservationQueryDisplayPage(coordinator: coordinator), ), ), ); await tester.pump(); // 1024x600 的布局缩放约为 0.55;文字与首页副屏一样保底 0.9, // 主标题 38 x 0.9 = 34.2 起步,说明文字没有跟着布局一起缩小。 final title = tester.renderObject( find.text('请刷饭卡或进行人脸识别'), ); expect(title.text.style!.fontSize, greaterThanOrEqualTo(34.2)); final description = tester.renderObject( find.text('识别成功后,这里将展示当前用户本人的预约菜单'), ); expect(description.text.style!.fontSize, greaterThanOrEqualTo(18.9)); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); coordinator.dispose(); }); testWidgets('键盘提示只在开启开关的等待识别布局展示', (tester) async { tester.view.physicalSize = const Size(1920, 1080); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); Future pumpState(ReservationQueryDisplayState state) async { final coordinator = ReservationQueryCoordinator(initialState: state); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, home: FTheme( data: FThemes.zinc.light, child: ReservationQueryDisplayPage(coordinator: coordinator), ), ), ); await tester.pump(); addTearDown(coordinator.dispose); } await pumpState( ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.idle, now: DateTime(2026, 7, 24, 8), ).copyWith(keyboardHintsEnabled: true), ); expect(find.text('请刷饭卡或进行人脸识别'), findsOneWidget); expect(find.text('键盘操作提示'), findsOneWidget); // 键盘模式左缘同时展示竖排“非触屏,按键盘操作”大字路牌。 expect( find.byKey(const ValueKey('reservation-non-touch-banner')), findsOneWidget, ); for (final char in const ['非', '触', '屏', '按', '键', '盘', '操', '作']) { expect(find.text(char), findsOneWidget); } // 竖排路牌为纯文字,顶部不放键盘图标(右侧“键盘操作提示”标题自带图标)。 expect( find.descendant( of: find.byKey(const ValueKey('reservation-non-touch-banner')), matching: find.byType(Icon), ), findsNothing, ); // 键盘图形由预渲染 3D 素材图提供:上排 人脸/饭卡/留白键,下排 加宽核销/取消。 final diagram = find.descendant( of: find.byKey(const ValueKey('reservation-keyboard-hint')), matching: find.byType(Image), ); expect(diagram, findsOneWidget); final image = tester.widget(diagram); expect( (image.image as AssetImage).assetName, 'assets/images/shortcut_keyboard_diagram.png', ); // 键帽文字已固化在素材图中,页面不再出现独立按键文案与“目前无作用”。 for (final label in const ['人脸', '饭卡', '核销', '取消', '目前无作用']) { expect(find.text(label), findsNothing); } // 键盘下方提供通俗操作说明:两段式按键、核销前提与取消用途。 expect(find.textContaining('再按一下就开始识别'), findsOneWidget); expect(find.textContaining('当天这一顿有预约才能成功'), findsOneWidget); expect(find.textContaining('回到开始的画面'), findsOneWidget); expect(tester.takeException(), isNull); // 关闭开关或非等待场景时不展示键盘提示与竖排大字路牌。 await pumpState( ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.idle, now: DateTime(2026, 7, 24, 8), ), ); expect( find.byKey(const ValueKey('reservation-keyboard-hint')), findsNothing, ); expect( find.byKey(const ValueKey('reservation-non-touch-banner')), findsNothing, ); await pumpState( ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.loading, now: DateTime(2026, 7, 24, 8), ).copyWith(keyboardHintsEnabled: true), ); expect( find.byKey(const ValueKey('reservation-keyboard-hint')), findsNothing, ); expect( find.byKey(const ValueKey('reservation-non-touch-banner')), findsNothing, ); }); testWidgets('小尺寸副屏开启键盘提示仍保持布局完整', (tester) async { tester.view.physicalSize = const Size(1024, 600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final coordinator = ReservationQueryCoordinator( initialState: ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.idle, now: DateTime(2026, 7, 24, 8), ).copyWith(keyboardHintsEnabled: true), ); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, home: FTheme( data: FThemes.zinc.light, child: ReservationQueryDisplayPage(coordinator: coordinator), ), ), ); await tester.pump(); expect(find.text('键盘操作提示'), findsOneWidget); expect( find.byKey(const ValueKey('reservation-keyboard-hint')), findsOneWidget, ); // 小尺寸下竖排大字路牌经 scaleDown 微缩后仍完整可读。 expect( find.byKey(const ValueKey('reservation-non-touch-banner')), findsOneWidget, ); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); coordinator.dispose(); }); testWidgets('等待放卡场景提示请放置饭卡且不显示键盘提示区', (tester) async { tester.view.physicalSize = const Size(1920, 1080); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final coordinator = ReservationQueryCoordinator( initialState: ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.waitingCard, now: DateTime(2026, 7, 24, 8), keyboardHintsEnabled: true, ), ); addTearDown(coordinator.dispose); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, home: FTheme( data: FThemes.zinc.light, child: ReservationQueryDisplayPage(coordinator: coordinator), ), ), ); await tester.pump(); // 等待放卡阶段用专属放卡引导替换通用查询文案。 expect(find.text('请放置饭卡'), findsOneWidget); expect(find.textContaining('将饭卡平放在读卡器上'), findsOneWidget); expect(find.text('正在查询本人预约'), findsNothing); // 识别进行中不再展示键盘操作提示与竖排路牌。 expect( find.byKey(const ValueKey('reservation-keyboard-hint')), findsNothing, ); expect( find.byKey(const ValueKey('reservation-non-touch-banner')), findsNothing, ); expect(find.text('键盘操作提示'), findsNothing); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); }); testWidgets('键盘模式成功页提示按核销键,关闭键盘模式不提示', (tester) async { tester.view.physicalSize = const Size(1920, 1080); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); Future pumpState({required bool keyboardHintsEnabled}) async { final coordinator = ReservationQueryCoordinator( initialState: ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.success, now: DateTime(2026, 7, 24, 8), keyboardHintsEnabled: keyboardHintsEnabled, ), ); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, home: FTheme( data: FThemes.zinc.light, child: ReservationQueryDisplayPage(coordinator: coordinator), ), ), ); await tester.pump(); // 成功态带隐私倒计时,必须在测试体内释放协调器,避免残留计时器。 coordinator.dispose(); } await pumpState(keyboardHintsEnabled: true); final bar = find.byKey(const ValueKey('reservation-redeem-hint')); expect(bar, findsOneWidget); // 图标同样由 RichText 承载,按内容匹配校验富文本完整语句。 expect( find.descendant( of: bar, matching: find.textContaining('请在键盘上按「核销」键完成核销', findRichText: true), ), findsOneWidget, ); expect(tester.takeException(), isNull); // 非键盘模式成功页保持原有布局,不加核销引导条。 await pumpState(keyboardHintsEnabled: false); expect( find.byKey(const ValueKey('reservation-redeem-hint')), findsNothing, ); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); }); testWidgets('持续人脸副屏同时保留实时预览、预约菜单和十秒倒计时', (tester) async { tester.view.physicalSize = const Size(1920, 1080); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final state = ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario.success, now: DateTime(2026, 8, 7, 10), ).copyWith( remainingSeconds: 10, continuousFaceRecognition: true, ); final coordinator = ReservationQueryCoordinator(initialState: state); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, home: FTheme( data: FThemes.zinc.light, child: ReservationQueryDisplayPage(coordinator: coordinator), ), ), ); await tester.pump(); expect( find.byKey(const ValueKey('sub-reservation-live-face-preview')), findsOneWidget, ); expect(find.byType(ReservationScheduleCard), findsNWidgets(10)); expect(find.text('预约结果展示中'), findsOneWidget); expect(find.textContaining('10 秒后识别下一位'), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); coordinator.dispose(); }); }