301 lines
10 KiB
Dart
301 lines
10 KiB
Dart
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<GeoSnapshot> resolve() async => const GeoSnapshot(
|
||
latitude: 25.041234,
|
||
longitude: 102.712345,
|
||
address: '云南省昆明市五华区莲花池正街某小区某栋某单元',
|
||
weather: '晴',
|
||
temperature: '25℃',
|
||
);
|
||
}
|
||
|
||
/// 定位失败:页面仍应可用,只是少了地点那一行
|
||
class _FailingGeoApi extends GeoApi {
|
||
@override
|
||
Future<GeoSnapshot> resolve() async => const GeoSnapshot(error: '没有定位权限');
|
||
}
|
||
|
||
/// 假照片接口:不真打网络。默认返回空列表 —— 入口页会在 initState 里拉列表,
|
||
/// 不给它替身的话测试会真的去连 localhost,每个用例白等好几秒。
|
||
class _FakePhotoApi extends PhotoApi {
|
||
_FakePhotoApi({this.photos = const []}) : super(Dio());
|
||
|
||
final List<PhotoItem> photos;
|
||
|
||
@override
|
||
Future<int?> upload({
|
||
required String filePath,
|
||
double? latitude,
|
||
double? longitude,
|
||
String? address,
|
||
String? weather,
|
||
String? remark,
|
||
DateTime? takenTime,
|
||
}) async =>
|
||
1;
|
||
|
||
@override
|
||
Future<PhotoPage> list({int page = 1, int size = 30}) async =>
|
||
PhotoPage(items: photos, total: photos.length, hasMore: false);
|
||
|
||
@override
|
||
Future<String> 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<void> settleIo(WidgetTester tester) async {
|
||
await tester.runAsync(() async {
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pump();
|
||
await tester.pump();
|
||
}
|
||
|
||
/// 弹出带输入框的弹层后不能用 pumpAndSettle:
|
||
/// TextField 的光标闪烁是无限动画,永远等不到静止。手动推两帧就够。
|
||
Future<void> 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': <String>['昆明市公安局', '昆明南站'],
|
||
});
|
||
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<TextField>(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);
|
||
});
|
||
}
|