import 'dart:async'; import 'dart:io'; import 'dart:ui' as ui; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/pages/setting/index.dart'; /// 通过真实设置页面覆盖设备未绑定、读取中、读取失败及重试,避免白屏回归。 void main() { TestWidgetsFlutterBinding.ensureInitialized(); late _BindingTestController controller; setUpAll(() async { // 使用工程实际的 Inter 度量,不能用 Widget 测试默认的等宽 Ahem 替代应用字体。 final loader = FontLoader('packages/forui/Inter'); for (final weight in ['Regular', 'SemiBold', 'Bold']) { loader.addFont(rootBundle .load('packages/forui/assets/fonts/inter/Inter-$weight.ttf')); } // 可选加载设备上的中文字体用于本地 PNG 目视检查,CI 不依赖外部字体文件。 final chineseFont = Platform.environment['PJ_SETTING_TEST_FONT']; if (chineseFont != null) { final chineseLoader = FontLoader('PJSettingCjk') ..addFont(File(chineseFont).readAsBytes().then(ByteData.sublistView)); await chineseLoader.load(); } await loader.load(); }); setUp(() { Get.testMode = true; Get.put(_BindingTestApp()); controller = _BindingTestController(); controller.cuurentMenuType.value = SettingMenuType.syncSetting; Get.put(controller); SyncCheckTool.instance.configs.clear(); SyncCheckTool.instance.records.clear(); SyncCheckTool.instance.pendingTasks.clear(); }); tearDown(() { Get.reset(); SyncCheckTool.instance.configs.clear(); SyncCheckTool.instance.records.clear(); SyncCheckTool.instance.pendingTasks.clear(); }); Future mount( WidgetTester tester, { Size size = const Size(1280, 720), double fontScale = 1.4, }) async { // 真机为 1920×1080、240dpi:1280×720 逻辑尺寸。用真实 View 指标初始化 ScreenUtil。 tester.view.devicePixelRatio = 1.5; tester.view.physicalSize = Size(size.width * 1.5, size.height * 1.5); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(1920, 1080), minTextAdapt: true, splitScreenMode: true, builder: (_, __) { final base = FThemes.zinc.light; final scaled = base.typography.scale(sizeScalar: fontScale); TextStyle withFallback(TextStyle style) => style.copyWith(fontFamilyFallback: const ['PJSettingCjk']); final theme = FThemeData.inherit( colorScheme: base.colorScheme, typography: scaled.copyWith( xs: withFallback(scaled.xs), sm: withFallback(scaled.sm), base: withFallback(scaled.base), lg: withFallback(scaled.lg), xl: withFallback(scaled.xl), xl2: withFallback(scaled.xl2), xl3: withFallback(scaled.xl3), xl4: withFallback(scaled.xl4), xl5: withFallback(scaled.xl5), xl6: withFallback(scaled.xl6), xl7: withFallback(scaled.xl7), xl8: withFallback(scaled.xl8), ), style: base.style.copyWith( borderRadius: BorderRadius.all(Radius.circular(12.w)), borderWidth: 1, iconStyle: base.style.iconStyle .copyWith(size: base.typography.xl2.fontSize), pagePadding: EdgeInsets.symmetric(vertical: 5.h, horizontal: 1.w), ), ); return GetMaterialApp( builder: (_, child) => FTheme(data: theme, child: child!), // 与实际终端一致,由设置页提供自己的 Material,不借测试外壳掩盖正文问题。 home: const RepaintBoundary( key: ValueKey('setting-layout-capture'), child: SettingPage(), ), ); }, ), ); await tester.pump(); } /// 保存当前真实 Widget 树的渲染图,只有设置本地输出目录时才写入,默认测试无文件副作用。 Future capture(WidgetTester tester, String name) async { final directory = Platform.environment['PJ_SETTING_SCREENSHOT_DIR']; if (directory == null) return; final boundary = tester.renderObject( find.byKey(const ValueKey('setting-layout-capture'))); await tester.runAsync(() async { final image = await boundary.toImage(pixelRatio: 1.5); final bytes = await image.toByteData(format: ui.ImageByteFormat.png); await File('$directory/$name.png') .writeAsBytes(bytes!.buffer.asUint8List()); image.dispose(); }); } /// 完整默认目录用于检查各列对齐,并包含成功、失败等不同长度的摘要。 void prepareTasks() { controller.readBinding = () async => true; SyncCheckTool.instance.configs.assignAll({ for (final config in SyncCheckTool.defaultConfigs) config.type: config, }); SyncCheckTool.instance.records.assignAll({ ScheduledTaskType.mqReconnect: const ScheduledTaskRecord(success: false, message: '消息服务暂未连接,请检查网络'), ScheduledTaskType.printCheck: ScheduledTaskRecord( lastRunAt: DateTime(2026, 10, 28, 11, 12), message: '打印任务检查完成'), }); } /// 验证规则与开关文案实际没有发生省略,不能只检查 Flutter 没有抛出布局异常。 void expectReadable(WidgetTester tester, ScheduledTaskType type) { for (final part in ['cron', 'idle', 'auto']) { final text = find.descendant( of: find.byKey(ValueKey('scheduled-task-$part-${type.name}')), matching: find.byType(Text), ); expect(text, findsOneWidget); expect( tester.renderObject(text).didExceedMaxLines, isFalse, reason: '${type.name} 的 $part 文字被截断'); } } testWidgets('未绑定的定时任务页说明原因,并能返回基本信息', (tester) async { await mount(tester); await tester.pumpAndSettle(); expect(find.text('设备尚未绑定商户,请先在“基本信息”中完成绑定'), findsOneWidget); expect(find.text('自动任务'), findsNothing); await tester.tap(find.text('前往基本信息')); await tester.pumpAndSettle(); expect(controller.cuurentMenuType.value, SettingMenuType.baseInfo); expect(find.text('暂无终端信息'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('绑定状态尚未读完时显示加载提示', (tester) async { final pending = Completer(); controller.readBinding = () => pending.future; await mount(tester); expect(find.text('正在读取设备配置…'), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); pending.complete(false); await tester.pumpAndSettle(); expect(find.text('前往基本信息'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('配置读取失败显示错误,重试成功后能进入定时任务正文', (tester) async { controller.readBinding = () => Future.error(StateError('读取失败')); await mount(tester); await tester.pumpAndSettle(); expect(find.text('设备配置读取失败,请重试'), findsOneWidget); controller.readBinding = () async => true; await tester.tap(find.text('重试')); await tester.pumpAndSettle(); expect(find.text('正在加载同步任务'), findsOneWidget); expect(find.text('设备配置读取失败,请重试'), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('绑定完成后重新进入定时任务会重新校验', (tester) async { await mount(tester); await tester.pumpAndSettle(); await tester.tap(find.text('前往基本信息')); await tester.pumpAndSettle(); controller.readBinding = () async => true; controller.openMenu(SettingMenuType.syncSetting); await tester.pumpAndSettle(); expect(find.text('正在加载同步任务'), findsOneWidget); expect(find.text('前往基本信息'), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('已有绑定且任务配置存在时可渲染任务内容', (tester) async { controller.readBinding = () async => true; SyncCheckTool.instance.configs.assignAll({ for (final config in SyncCheckTool.defaultConfigs) config.type: config, }); await mount(tester); await tester.pumpAndSettle(); expect(find.text('自动任务'), findsOneWidget); expect(find.text('立即执行'), findsNWidgets(ScheduledTaskType.values.length)); expect(tester.takeException(), isNull); }); for (final scale in [1.2, 1.4, 1.5]) { testWidgets('1280 逻辑宽度与 $scale 倍字号保持单行、固定按钮和完整文字', (tester) async { prepareTasks(); await mount(tester, fontScale: scale); await tester.pumpAndSettle(); final type = ScheduledTaskType.mqReconnect; final run = tester .getRect(find.byKey(ValueKey('scheduled-task-run-${type.name}'))); final cron = tester .getRect(find.byKey(ValueKey('scheduled-task-cron-${type.name}'))); expect(run.width, lessThan(220), reason: '执行按钮不应扩展成整行'); expect(run.center.dy, closeTo(cron.center.dy, 1), reason: '正常宽度下执行按钮与规则应在同一行'); final nextCron = tester.getRect( find.byKey(const ValueKey('scheduled-task-cron-printCheck'))); expect(cron.left, closeTo(nextCron.left, 1), reason: '各任务列应对齐'); for (final type in ScheduledTaskType.values) { expectReadable(tester, type); } if (scale == 1.4) await capture(tester, 'scheduled-tasks-1280-font-1.4'); expect(tester.takeException(), isNull); }); } testWidgets('1024 窄屏使用明确的双行布局,执行按钮保持原宽且规则不截断', (tester) async { prepareTasks(); SyncCheckTool.instance.pendingTasks.add(ScheduledTaskType.mqReconnect); await mount(tester, size: const Size(1024, 768), fontScale: 1.5); await tester.pumpAndSettle(); final run = tester .getRect(find.byKey(const ValueKey('scheduled-task-run-mqReconnect'))); final cron = tester .getRect(find.byKey(const ValueKey('scheduled-task-cron-mqReconnect'))); expect(run.width, lessThan(220)); expect(cron.top, greaterThan(run.top), reason: '窄屏规则移至明确的第二行'); expect(find.text('再执行一次'), findsOneWidget); expectReadable(tester, ScheduledTaskType.mqReconnect); await capture(tester, 'scheduled-tasks-1024-font-1.5'); expect(tester.takeException(), isNull); }); } /// 避免测试启动 AppControl 的硬件、数据库和后台调度。 class _BindingTestApp extends AppControl { /// 不启动真实设备资源。 @override // ignore: must_call_super void onInit() {} /// 不执行真实应用就绪动作。 @override void onReady() {} /// 当前实例没有硬件句柄,不走生产释放流程。 @override void onClose() {} } /// 只替换绑定信息的读取,其余菜单切换和实际页面构建均使用正式代码。 class _BindingTestController extends SettingController { /// 默认模拟本机尚未关联商户,测试可替换为未完成或失败的读取。 Future Function() readBinding = () async => false; /// 不从测试环境读取 SQLite 或初始化 Cron。 @override // ignore: must_call_super void onInit() {} /// 页面由正式 GetBuilder 驱动,不额外触发应用启动动作。 @override void onReady() {} /// 只释放已创建的字号控制器,未创建的 MQTT 订阅不应访问。 @override void onClose() => globalFontSizeC.dispose(); /// 返回受控的本机绑定状态,不改变正式判断规则。 @override Future checkIsBind() => readBinding(); }