Files
Aq-Accounting-Flutter/test/watermark_layout_test.dart
2026-09-28 11:39:19 +08:00

301 lines
10 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: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);
});
}