546 lines
18 KiB
Dart
546 lines
18 KiB
Dart
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<RenderParagraph>(
|
|
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<RenderParagraph>(
|
|
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<RenderParagraph>(
|
|
find.text('请刷饭卡或进行人脸识别'),
|
|
);
|
|
expect(title.text.style!.fontSize, greaterThanOrEqualTo(34.2));
|
|
final description = tester.renderObject<RenderParagraph>(
|
|
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<void> 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<Image>(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<void> 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();
|
|
});
|
|
}
|