Files
pj_supermarket_cloud/test/setting_binding_guard_test.dart
2026-10-28 19:11:38 +08:00

299 lines
12 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}