299 lines
12 KiB
Dart
299 lines
12 KiB
Dart
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<AppControl>(_BindingTestApp());
|
||
controller = _BindingTestController();
|
||
controller.cuurentMenuType.value = SettingMenuType.syncSetting;
|
||
Get.put<SettingController>(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<void> 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<void> capture(WidgetTester tester, String name) async {
|
||
final directory = Platform.environment['PJ_SETTING_SCREENSHOT_DIR'];
|
||
if (directory == null) return;
|
||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||
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<RenderParagraph>(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<bool>();
|
||
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<bool>.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<bool> 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<bool> checkIsBind() => readBinding();
|
||
}
|