import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; import 'package:shared_preferences/shared_preferences.dart'; import 'package:qiangbao_accounting/api/geo_api.dart'; import 'package:qiangbao_accounting/api/photo_api.dart'; import 'package:qiangbao_accounting/core/storage/local_storage.dart'; import 'package:qiangbao_accounting/models/photo.dart'; import 'package:qiangbao_accounting/pages/watermark/watermark_camera_page.dart'; import 'package:qiangbao_accounting/pages/watermark/watermark_edit_page.dart'; /// 水印相机的页面布局回归测试。 /// /// 价值在之前抓过真 bug:记账首页顶部一行在 393 宽的常规手机上溢出 32px, /// 光看代码看不出来。 /// /// 这里只断言「不溢出、不抛异常」,具体样式靠人眼验收。 /// /// 两个测试上的坑(踩过): /// 1. 页面上的加载圈是**无限动画**,`pumpAndSettle` 永远等不到静止 → 超时。 /// 所以编辑页只能手动 pump。 /// 2. 读文件/解码图片是**真实异步 I/O**,得用 `tester.runAsync` 才跑得完。 /// 假定位:不发网络请求,直接返回一份完整快照 class _FakeGeoApi extends GeoApi { @override Future resolve() async => const GeoSnapshot( latitude: 25.041234, longitude: 102.712345, address: '云南省昆明市五华区莲花池正街某小区某栋某单元', weather: '晴', temperature: '25℃', ); } /// 定位失败:页面仍应可用,只是少了地点那一行 class _FailingGeoApi extends GeoApi { @override Future resolve() async => const GeoSnapshot(error: '没有定位权限'); } /// 假照片接口:不真打网络。默认返回空列表 —— 入口页会在 initState 里拉列表, /// 不给它替身的话测试会真的去连 localhost,每个用例白等好几秒。 class _FakePhotoApi extends PhotoApi { _FakePhotoApi({this.photos = const []}) : super(Dio()); final List photos; @override Future upload({ required String filePath, double? latitude, double? longitude, String? address, String? weather, String? remark, DateTime? takenTime, }) async => 1; @override Future list({int page = 1, int size = 30}) async => PhotoPage(items: photos, total: photos.length, hasMore: false); @override Future downloadOriginal( String url, { void Function(int received, int total)? onProgress, }) async => ''; } void main() { const sizes = [ ('iPhone SE 窄屏', Size(320, 568)), ('常见安卓机', Size(393, 873)), ('大屏', Size(480, 1000)), ]; void useSize(WidgetTester tester, Size size) { tester.view.physicalSize = size * 3; tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); } /// 造一张真实的小图(编辑页要读它的文件头拿尺寸) File makePhoto(WidgetTester tester) { final dir = Directory.systemTemp.createTempSync('wm_layout_test'); addTearDown(() { // Image.file 可能还占着文件句柄,删不掉就算了,别让清理把测试搞挂 try { dir.deleteSync(recursive: true); } catch (_) {} }); return File('${dir.path}/photo.png') ..writeAsBytesSync(img.encodePng(img.Image(width: 120, height: 90))); } /// 等真实 I/O 跑完再渲染一帧 Future settleIo(WidgetTester tester) async { await tester.runAsync(() async { await Future.delayed(const Duration(milliseconds: 300)); }); await tester.pump(); await tester.pump(); } /// 弹出带输入框的弹层后不能用 pumpAndSettle: /// TextField 的光标闪烁是无限动画,永远等不到静止。手动推两帧就够。 Future openSheet(WidgetTester tester, String buttonText) async { await tester.tap(find.text(buttonText)); await tester.pump(); // 开始入场动画 await tester.pump(const Duration(milliseconds: 400)); // 动画走完 } for (final (label, size) in sizes) { testWidgets('入口页 $label 不溢出', (tester) async { useSize(tester, size); await tester.pumpWidget( ProviderScope( overrides: [photoApiProvider.overrideWithValue(_FakePhotoApi())], child: const MaterialApp(home: WatermarkCameraPage()), ), ); await tester.pumpAndSettle(); expect(find.text('水印相机'), findsOneWidget); expect(find.text('拍照'), findsOneWidget); expect(find.text('从相册选'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('编辑页 $label 不溢出', (tester) async { useSize(tester, size); final photo = makePhoto(tester); await tester.pumpWidget( ProviderScope( overrides: [ geoApiProvider.overrideWithValue(_FakeGeoApi()), photoApiProvider.overrideWithValue(_FakePhotoApi()), ], child: MaterialApp(home: WatermarkEditPage(imagePath: photo.path)), ), ); await settleIo(tester); expect(find.text('加水印'), findsOneWidget); expect(find.text('保存'), findsOneWidget); // 假定位的地址应该显示出来了(顺带验证异步链路是通的) expect(find.textContaining('昆明市'), findsOneWidget); expect(tester.takeException(), isNull); }); } testWidgets('设置弹层在窄屏也不溢出', (tester) async { useSize(tester, const Size(320, 568)); await tester.pumpWidget( ProviderScope( overrides: [photoApiProvider.overrideWithValue(_FakePhotoApi())], child: const MaterialApp(home: WatermarkCameraPage()), ), ); await tester.pumpAndSettle(); // 点右上角进设置 await tester.tap(find.byIcon(Icons.tune)); await tester.pumpAndSettle(); expect(find.text('水印设置'), findsOneWidget); expect(find.text('天气温度'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('已上传的照片在入口页以网格显示', (tester) async { useSize(tester, const Size(393, 873)); const photos = [ PhotoItem( id: 1, url: '/uploads/photo/a.jpg', thumbUrl: '/uploads/photo/a.thumb.jpg', takenAt: '2026-09-28 14:30', address: '昆明市五华区', weather: '晴 25℃', remark: '', latitude: 25.0, longitude: 102.7, ), PhotoItem( id: 2, url: '/uploads/photo/b.jpg', // 缩略图没生成出来的情况:应该退回用原图,而不是空一块 thumbUrl: null, takenAt: '2026-09-27 09:00', address: '', weather: '', remark: '', latitude: null, longitude: null, ), ]; await tester.pumpWidget( ProviderScope( overrides: [ photoApiProvider.overrideWithValue(_FakePhotoApi(photos: photos)), ], child: const MaterialApp(home: WatermarkCameraPage()), ), ); await tester.pumpAndSettle(); expect(find.text('最近拍摄'), findsOneWidget); expect(find.text('共 2 张'), findsOneWidget); expect(find.byType(GridView), findsOneWidget); // 测试环境加载不到网络图,会落到 errorBuilder 的占位图标;数量仍然对 expect(find.byType(Image), findsNWidgets(2)); expect(tester.takeException(), isNull); }); testWidgets('编辑内容弹层在窄屏也不溢出(时间 + 常用地址 + 四个输入框)', (tester) async { useSize(tester, const Size(320, 568)); final photo = makePhoto(tester); await tester.pumpWidget( ProviderScope( overrides: [ geoApiProvider.overrideWithValue(_FakeGeoApi()), photoApiProvider.overrideWithValue(_FakePhotoApi()), ], child: MaterialApp(home: WatermarkEditPage(imagePath: photo.path)), ), ); await settleIo(tester); await openSheet(tester, '编辑内容'); expect(find.text('时间'), findsOneWidget); expect(find.text('常用地址'), findsOneWidget); expect(find.text('备注'), findsOneWidget); expect(tester.takeException(), isNull); }); // 放在最后:它会初始化 LocalStorage 单例(单例的 _prefs 只取第一次) testWidgets('常用地址:点一下填进地址框', (tester) async { useSize(tester, const Size(393, 873)); SharedPreferences.setMockInitialValues({ 'watermark_quick_addresses': ['昆明市公安局', '昆明南站'], }); await LocalStorage.instance.init(); final photo = makePhoto(tester); await tester.pumpWidget( ProviderScope( overrides: [ geoApiProvider.overrideWithValue(_FakeGeoApi()), photoApiProvider.overrideWithValue(_FakePhotoApi()), ], child: MaterialApp(home: WatermarkEditPage(imagePath: photo.path)), ), ); await settleIo(tester); await openSheet(tester, '编辑内容'); expect(find.text('昆明市公安局'), findsOneWidget); await tester.tap(find.text('昆明市公安局')); await tester.pump(); // 弹层里第一个输入框是「地址」,点快捷地址应该把它填上 final addressField = tester.widget(find.byType(TextField).at(0)); expect(addressField.controller?.text, '昆明市公安局'); expect(tester.takeException(), isNull); }); testWidgets('定位失败时编辑页仍可用(只是少一行地点)', (tester) async { useSize(tester, const Size(393, 873)); final photo = makePhoto(tester); await tester.pumpWidget( ProviderScope( overrides: [ geoApiProvider.overrideWithValue(_FailingGeoApi()), photoApiProvider.overrideWithValue(_FakePhotoApi()), ], child: MaterialApp(home: WatermarkEditPage(imagePath: photo.path)), ), ); await settleIo(tester); expect(find.textContaining('没有定位权限'), findsOneWidget); expect(tester.takeException(), isNull); }); }