Files
biz_xfj_cloud/test/common/app_dialog_test.dart
T
2026-08-09 14:45:18 +08:00

454 lines
16 KiB
Dart

import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const runtimeChannel = MethodChannel('biz_xfj/runtime_environment');
late int vibrationCount;
setUp(() {
vibrationCount = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(runtimeChannel, (call) async {
if (call.method == 'vibrate') {
vibrationCount++;
return true;
}
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(runtimeChannel, null);
});
testWidgets('允许关闭时遮罩点击返回空结果并移除弹框', (tester) async {
String? result = 'pending';
await tester.pumpWidget(
_dialogTestApp(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () async {
result = await showAppDialog<String>(
context: context,
builder: (dialogContext) => AppDialog(
key: const ValueKey('allowed-dialog'),
title: const Text('允许关闭'),
content: const Text('弹框内容'),
onClose: () => Navigator.of(dialogContext).pop('button'),
),
);
},
child: const Text('打开允许关闭弹框'),
),
),
),
);
await tester.tap(find.text('打开允许关闭弹框'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('allowed-dialog')), findsOneWidget);
await tester.tapAt(const Offset(4, 4));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('allowed-dialog')), findsNothing);
expect(result, isNull);
});
testWidgets('默认弹框位于顶部安全区下方并保留固定间隔', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(800, 600);
addTearDown(tester.view.reset);
const viewPadding = EdgeInsets.fromLTRB(16, 18, 16, 12);
await tester.pumpWidget(
_dialogTestApp(
viewPadding: viewPadding,
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAppDialog<void>(
context: context,
builder: (dialogContext) => AppDialog(
key: const ValueKey('top-dialog'),
title: const Text('顶部弹框'),
width: 360,
maxWidth: 360,
content: const Text('默认不再相对整个屏幕居中'),
onClose: () => Navigator.of(dialogContext).pop(),
),
),
child: const Text('打开顶部弹框'),
),
),
),
);
await tester.tap(find.text('打开顶部弹框'));
await tester.pumpAndSettle();
final rect = tester.getRect(find.byKey(const ValueKey('top-dialog')));
expect(
rect.top,
closeTo(viewPadding.top + appDialogDefaultTopSpacing, 0.01),
);
expect(
rect.left,
greaterThanOrEqualTo(viewPadding.left + appDialogDefaultEdgeSpacing),
);
expect(
rect.right,
lessThanOrEqualTo(
800 - viewPadding.right - appDialogDefaultEdgeSpacing,
),
);
expect(tester.takeException(), isNull);
});
testWidgets('键盘出现后弹框动态缩入键盘上方可用区域', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(800, 600);
addTearDown(tester.view.reset);
final viewInsets = ValueNotifier<EdgeInsets>(EdgeInsets.zero);
addTearDown(viewInsets.dispose);
await tester.pumpWidget(
_dialogTestApp(
viewInsetsListenable: viewInsets,
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAppDialog<void>(
context: context,
builder: (dialogContext) => AppDialog(
key: const ValueKey('keyboard-dialog'),
title: const Text('输入信息'),
width: 420,
maxWidth: 420,
height: 400,
content: const TextField(
key: ValueKey('keyboard-dialog-field'),
autofocus: true,
),
onClose: () => Navigator.of(dialogContext).pop(),
),
),
child: const Text('打开输入弹框'),
),
),
),
);
await tester.tap(find.text('打开输入弹框'));
await tester.pumpAndSettle();
final initialRect = tester.getRect(
find.byKey(const ValueKey('keyboard-dialog')),
);
expect(initialRect.top, closeTo(appDialogDefaultTopSpacing, 0.01));
viewInsets.value = const EdgeInsets.only(bottom: 260);
await tester.pump();
await tester.pump(appDialogDefaultTransitionDuration);
final keyboardRect = tester.getRect(
find.byKey(const ValueKey('keyboard-dialog')),
);
final fieldRect = tester.getRect(
find.byKey(const ValueKey('keyboard-dialog-field')),
);
final keyboardTop = 600 - viewInsets.value.bottom;
expect(keyboardRect.top, closeTo(appDialogDefaultTopSpacing, 0.01));
expect(
keyboardRect.bottom,
lessThanOrEqualTo(keyboardTop - appDialogDefaultEdgeSpacing),
);
expect(fieldRect.bottom, lessThanOrEqualTo(keyboardRect.bottom));
expect(tester.takeException(), isNull);
});
testWidgets('禁止关闭时节流震动且减少动态模式使用边框高亮', (tester) async {
await tester.pumpWidget(
_dialogTestApp(
disableAnimations: true,
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAppDialog<void>(
context: context,
barrierDismissible: false,
canPop: false,
builder: (_) => const AppDialog(
key: ValueKey('blocked-dialog'),
title: Text('禁止关闭'),
content: Text('业务处理中'),
onClose: null,
),
),
child: const Text('打开禁止关闭弹框'),
),
),
),
);
await tester.tap(find.text('打开禁止关闭弹框'));
await tester.pumpAndSettle();
await Navigator.of(
tester.element(find.byKey(const ValueKey('blocked-dialog'))),
).maybePop();
await tester.pump();
expect(vibrationCount, 1);
expect(find.byKey(const ValueKey('blocked-dialog')), findsOneWidget);
expect(
_dialogBorderColor(tester, 'blocked-dialog'), XfjDesignTokens.warning);
await tester.tapAt(const Offset(4, 4));
await tester.pump();
expect(vibrationCount, 1, reason: '节流窗口内的连续点击不得重复震动');
expect(find.byKey(const ValueKey('blocked-dialog')), findsOneWidget);
await tester.pump(const Duration(milliseconds: 250));
expect(_dialogBorderColor(tester, 'blocked-dialog'), XfjDesignTokens.line);
});
testWidgets('标题栏拖动后弹框仍完整保留在安全区域', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(800, 600);
addTearDown(tester.view.reset);
await tester.pumpWidget(
_dialogTestApp(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAppDialog<void>(
context: context,
builder: (dialogContext) => AppDialog(
key: const ValueKey('draggable-dialog'),
title: const Text('拖动标题栏'),
width: 360,
maxWidth: 360,
height: 260,
draggable: true,
content: const Text('可拖动内容'),
onClose: () => Navigator.of(dialogContext).pop(),
),
),
child: const Text('打开可拖动弹框'),
),
),
),
);
await tester.tap(find.text('打开可拖动弹框'));
await tester.pumpAndSettle();
await tester.drag(find.text('拖动标题栏'), const Offset(2000, 2000));
await tester.pump();
var rect = tester.getRect(find.byKey(const ValueKey('draggable-dialog')));
expect(rect.right, lessThanOrEqualTo(792));
expect(rect.bottom, lessThanOrEqualTo(592));
final bottomRightRect = rect;
await tester.drag(find.text('拖动标题栏'), const Offset(2000, 2000));
await tester.pump();
rect = tester.getRect(find.byKey(const ValueKey('draggable-dialog')));
expect(rect, bottomRightRect, reason: '到达边缘后继续同方向拖动不得反复修正位置');
expect(find.byKey(const ValueKey('draggable-dialog')), findsOneWidget);
await tester.drag(find.text('拖动标题栏'), const Offset(-4000, -4000));
await tester.pump();
rect = tester.getRect(find.byKey(const ValueKey('draggable-dialog')));
expect(rect.left, greaterThanOrEqualTo(8));
expect(rect.top, greaterThanOrEqualTo(8));
expect(tester.takeException(), isNull);
});
testWidgets('拖动和尺寸约束保留系统安全区', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(800, 600);
addTearDown(tester.view.reset);
const viewPadding = EdgeInsets.fromLTRB(24, 18, 32, 20);
await tester.pumpWidget(
_dialogTestApp(
viewPadding: viewPadding,
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAppDialog<void>(
context: context,
builder: (dialogContext) => AppDialog(
key: const ValueKey('safe-dialog'),
title: const Text('安全区弹框'),
width: 790,
maxWidth: 790,
height: 560,
draggable: true,
content: const Text('安全区内容'),
onClose: () => Navigator.of(dialogContext).pop(),
),
),
child: const Text('打开安全区弹框'),
),
),
),
);
await tester.tap(find.text('打开安全区弹框'));
await tester.pumpAndSettle();
var rect = tester.getRect(find.byKey(const ValueKey('safe-dialog')));
expect(rect.left, greaterThanOrEqualTo(viewPadding.left + 8));
expect(rect.right, lessThanOrEqualTo(800 - viewPadding.right - 8));
expect(rect.top, greaterThanOrEqualTo(viewPadding.top + 8));
expect(rect.bottom, lessThanOrEqualTo(600 - viewPadding.bottom - 8));
await tester.drag(find.text('安全区弹框'), const Offset(3000, -3000));
await tester.pump();
rect = tester.getRect(find.byKey(const ValueKey('safe-dialog')));
expect(rect.left, greaterThanOrEqualTo(viewPadding.left + 8));
expect(rect.right, lessThanOrEqualTo(800 - viewPadding.right - 8));
expect(rect.top, greaterThanOrEqualTo(viewPadding.top + 8));
expect(rect.bottom, lessThanOrEqualTo(600 - viewPadding.bottom - 8));
expect(tester.takeException(), isNull);
});
testWidgets('ForUI 操作按钮在空间足够时保持横向排列', (tester) async {
await tester.pumpWidget(
_dialogTestApp(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAppDialog<void>(
context: context,
builder: (dialogContext) => AppDialog(
title: const Text('操作区布局'),
width: 680.w,
maxWidth: 680.w,
content: const Text('弹框内容'),
onClose: () => Navigator.of(dialogContext).pop(),
actions: [
FButton(
key: const ValueKey('cancel-action'),
style: FButtonStyle.outline,
label: const Text('取消'),
onPress: () {},
),
FButton(
key: const ValueKey('confirm-action'),
label: const Text('验证并继续'),
onPress: () {},
),
],
),
),
child: const Text('打开操作区弹框'),
),
),
),
);
await tester.tap(find.text('打开操作区弹框'));
await tester.pumpAndSettle();
final cancelRect =
tester.getRect(find.byKey(const ValueKey('cancel-action')));
final confirmRect = tester.getRect(
find.byKey(const ValueKey('confirm-action')),
);
expect(cancelRect.top, closeTo(confirmRect.top, 0.01));
expect(cancelRect.right, lessThan(confirmRect.left));
expect(cancelRect.height, greaterThanOrEqualTo(48.h));
expect(confirmRect.height, greaterThanOrEqualTo(48.h));
expect(tester.takeException(), isNull);
});
testWidgets('终端操作验证保留上下留白并横向排列操作按钮', (tester) async {
await tester.pumpWidget(
_dialogTestApp(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => terminalAdminPasswordDialog(
context,
actionLabel: '进入设置',
),
child: const Text('打开终端操作验证'),
),
),
),
);
await tester.tap(find.text('打开终端操作验证'));
await tester.pumpAndSettle();
final headerDivider = tester.getRect(find.byType(Divider).first);
final actionDivider = tester.getRect(find.byType(Divider).last);
final descriptionRect = tester.getRect(
find.text('进入设置前需要验证管理密码。'),
);
final passwordFieldRect = tester.getRect(find.byType(FTextField));
final cancelRect = tester.getRect(find.text('取消'));
final confirmRect = tester.getRect(find.text('验证并继续'));
expect(
descriptionRect.top - headerDivider.bottom, greaterThanOrEqualTo(19));
expect(
actionDivider.top - passwordFieldRect.bottom, greaterThanOrEqualTo(19));
expect(cancelRect.top, closeTo(confirmRect.top, 0.01));
expect(cancelRect.right, lessThan(confirmRect.left));
expect(tester.takeException(), isNull);
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
});
}
/// 构建具备项目 ForUI 主题和 ScreenUtil 设计基准的弹框测试环境。
Widget _dialogTestApp({
required Widget child,
bool disableAnimations = false,
EdgeInsets viewPadding = EdgeInsets.zero,
ValueListenable<EdgeInsets>? viewInsetsListenable,
}) {
return ScreenUtilInit(
designSize: const Size(1920, 1080),
minTextAdapt: true,
splitScreenMode: true,
builder: (_, __) => MaterialApp(
builder: (context, appChild) {
final mediaData = MediaQuery.of(context).copyWith(
disableAnimations: disableAnimations,
padding: viewPadding,
viewPadding: viewPadding,
);
final themedChild = FTheme(
data: FThemes.zinc.light,
child: appChild!,
);
final viewInsets = viewInsetsListenable;
if (viewInsets == null) {
return MediaQuery(data: mediaData, child: themedChild);
}
return ValueListenableBuilder<EdgeInsets>(
valueListenable: viewInsets,
builder: (_, insets, child) => MediaQuery(
data: mediaData.copyWith(viewInsets: insets),
child: child!,
),
child: themedChild,
);
},
home: Scaffold(body: Center(child: child)),
),
);
}
/// 读取指定弹框表面的当前边框颜色,用于验证减少动态效果下的阻止关闭反馈。
Color _dialogBorderColor(WidgetTester tester, String dialogKey) {
final materials = tester.widgetList<Material>(
find.descendant(
of: find.byKey(ValueKey(dialogKey)),
matching: find.byType(Material),
),
);
final surface = materials.firstWhere(
(material) => material.shape is RoundedRectangleBorder,
);
return (surface.shape! as RoundedRectangleBorder).side.color;
}