水印优化+ORC优化
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:qiangbao_accounting/api/geo_api.dart';
|
||||
import 'package:qiangbao_accounting/core/network/api_exception.dart';
|
||||
|
||||
/// 用一个假的 HTTP 适配器接管请求,按 URL 分发不同的响应。
|
||||
/// 这样不用真的联网,也能把「百度失败 → Nominatim 兜底」整条链路跑通。
|
||||
class _FakeAdapter implements HttpClientAdapter {
|
||||
_FakeAdapter(this.handler);
|
||||
|
||||
final Future<ResponseBody> Function(RequestOptions options) handler;
|
||||
|
||||
/// 记录每次请求,用来断言请求头和参数
|
||||
final List<RequestOptions> requests = [];
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) {
|
||||
requests.add(options);
|
||||
return handler(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
ResponseBody _json(String body) => ResponseBody.fromString(
|
||||
body,
|
||||
200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: ['application/json'],
|
||||
},
|
||||
);
|
||||
|
||||
bool _isBaidu(RequestOptions o) => o.uri.host.contains('baidu');
|
||||
bool _isNominatim(RequestOptions o) => o.uri.host.contains('nominatim');
|
||||
|
||||
void main() {
|
||||
GeoApi apiWith(_FakeAdapter adapter) => GeoApi(dio: Dio()..httpClientAdapter = adapter);
|
||||
|
||||
group('地址解析:百度优先、Nominatim 兜底', () {
|
||||
test('百度正常时用百度的结果(国内数据最准)', () async {
|
||||
final adapter = _FakeAdapter((o) async {
|
||||
if (_isBaidu(o)) {
|
||||
return _json(
|
||||
'{"status":0,"result":{"formatted_address":"云南省昆明市五华区护国街道南屏街112"}}',
|
||||
);
|
||||
}
|
||||
fail('百度成功了就不该再去打 Nominatim');
|
||||
});
|
||||
|
||||
final address = await apiWith(adapter)
|
||||
.reverseAddress(latitude: 25.041234, longitude: 102.712345);
|
||||
|
||||
expect(address, '云南省昆明市五华区护国街道南屏街112');
|
||||
expect(adapter.requests.length, 1);
|
||||
});
|
||||
|
||||
test('★ 百度配额用完时自动降级到 Nominatim(用户实际撞到的情况)', () async {
|
||||
final adapter = _FakeAdapter((o) async {
|
||||
if (_isBaidu(o)) {
|
||||
return _json('{"status":4,"msg":"AK 配额校验失败"}');
|
||||
}
|
||||
return _json(
|
||||
'{"display_name":"庆云街, 护国街道, 五华区, 昆明市, 云南省, 650011, 中国",'
|
||||
'"address":{"road":"庆云街","suburb":"护国街道","district":"五华区",'
|
||||
'"city":"昆明市","state":"云南省","postcode":"650011","country":"中国"}}',
|
||||
);
|
||||
});
|
||||
|
||||
final address = await apiWith(adapter)
|
||||
.reverseAddress(latitude: 25.041234, longitude: 102.712345);
|
||||
|
||||
// 拼成中文习惯的「大 → 小」,而不是 Nominatim 原生的「小 → 大」
|
||||
expect(address, '云南省昆明市五华区护国街道庆云街');
|
||||
expect(adapter.requests.length, 2);
|
||||
});
|
||||
|
||||
test('百度网络异常也会降级(不只是配额问题)', () async {
|
||||
final adapter = _FakeAdapter((o) async {
|
||||
if (_isBaidu(o)) {
|
||||
throw DioException(requestOptions: o, message: 'connection timeout');
|
||||
}
|
||||
return _json('{"address":{"state":"云南省","city":"昆明市","district":"五华区"}}');
|
||||
});
|
||||
|
||||
final address = await apiWith(adapter)
|
||||
.reverseAddress(latitude: 25.0, longitude: 102.7);
|
||||
|
||||
expect(address, '云南省昆明市五华区');
|
||||
});
|
||||
|
||||
test('两个都挂时,把百度的原因带出来(而不是一句笼统的失败)', () async {
|
||||
final adapter = _FakeAdapter((o) async {
|
||||
if (_isBaidu(o)) return _json('{"status":4,"msg":"AK 配额校验失败"}');
|
||||
throw DioException(requestOptions: o, message: 'timeout');
|
||||
});
|
||||
|
||||
expect(
|
||||
() => apiWith(adapter).reverseAddress(latitude: 25.0, longitude: 102.7),
|
||||
throwsA(
|
||||
isA<ApiException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('配额'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('Nominatim 只给 display_name 时也能拼出地址,且滤掉邮编和国家', () async {
|
||||
final adapter = _FakeAdapter((o) async {
|
||||
if (_isBaidu(o)) return _json('{"status":1,"msg":"失败"}');
|
||||
return _json(
|
||||
'{"display_name":"庆云街, 护国街道, 五华区, 昆明市, 云南省, 650011, 中国"}',
|
||||
);
|
||||
});
|
||||
|
||||
final address = await apiWith(adapter)
|
||||
.reverseAddress(latitude: 25.0, longitude: 102.7);
|
||||
|
||||
expect(address, '云南省昆明市五华区护国街道庆云街');
|
||||
});
|
||||
|
||||
test('Nominatim 请求必须带 User-Agent(它的使用政策要求,不带会被拒)', () async {
|
||||
final adapter = _FakeAdapter((o) async {
|
||||
if (_isBaidu(o)) return _json('{"status":1,"msg":"失败"}');
|
||||
return _json('{"address":{"state":"云南省"}}');
|
||||
});
|
||||
|
||||
await apiWith(adapter).reverseAddress(latitude: 25.0, longitude: 102.7);
|
||||
|
||||
final nominatim = adapter.requests.firstWhere(_isNominatim);
|
||||
expect(nominatim.headers['User-Agent'], isNotNull);
|
||||
expect(nominatim.headers['User-Agent'], contains('Qiangbao'));
|
||||
});
|
||||
|
||||
test('百度请求带 coordtype=wgs84ll(geolocator 给的是 WGS84)', () async {
|
||||
final adapter = _FakeAdapter((o) async {
|
||||
if (_isBaidu(o)) {
|
||||
return _json('{"status":0,"result":{"formatted_address":"昆明"}}');
|
||||
}
|
||||
return _json('{}');
|
||||
});
|
||||
|
||||
await apiWith(adapter).reverseAddress(latitude: 25.0, longitude: 102.7);
|
||||
|
||||
final baidu = adapter.requests.firstWhere(_isBaidu);
|
||||
expect(baidu.uri.queryParameters['coordtype'], 'wgs84ll');
|
||||
expect(baidu.uri.queryParameters['location'], '25.0,102.7');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,9 +5,12 @@ 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';
|
||||
|
||||
@@ -41,9 +44,12 @@ class _FailingGeoApi extends GeoApi {
|
||||
Future<GeoSnapshot> resolve() async => const GeoSnapshot(error: '没有定位权限');
|
||||
}
|
||||
|
||||
/// 假上传:立刻成功,避免测试里真的打网络
|
||||
/// 假照片接口:不真打网络。默认返回空列表 —— 入口页会在 initState 里拉列表,
|
||||
/// 不给它替身的话测试会真的去连 localhost,每个用例白等好几秒。
|
||||
class _FakePhotoApi extends PhotoApi {
|
||||
_FakePhotoApi() : super(Dio());
|
||||
_FakePhotoApi({this.photos = const []}) : super(Dio());
|
||||
|
||||
final List<PhotoItem> photos;
|
||||
|
||||
@override
|
||||
Future<int?> upload({
|
||||
@@ -56,6 +62,17 @@ class _FakePhotoApi extends PhotoApi {
|
||||
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() {
|
||||
@@ -93,11 +110,22 @@ void main() {
|
||||
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(
|
||||
const ProviderScope(child: MaterialApp(home: WatermarkCameraPage())),
|
||||
ProviderScope(
|
||||
overrides: [photoApiProvider.overrideWithValue(_FakePhotoApi())],
|
||||
child: const MaterialApp(home: WatermarkCameraPage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -133,7 +161,10 @@ void main() {
|
||||
testWidgets('设置弹层在窄屏也不溢出', (tester) async {
|
||||
useSize(tester, const Size(320, 568));
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(child: MaterialApp(home: WatermarkCameraPage())),
|
||||
ProviderScope(
|
||||
overrides: [photoApiProvider.overrideWithValue(_FakePhotoApi())],
|
||||
child: const MaterialApp(home: WatermarkCameraPage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -146,6 +177,108 @@ void main() {
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
import 'package:qiangbao_accounting/models/photo.dart';
|
||||
import 'package:qiangbao_accounting/models/watermark.dart';
|
||||
import 'package:qiangbao_accounting/pages/watermark/watermark_painter.dart';
|
||||
|
||||
@@ -211,6 +212,57 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('照片记录还原成水印数据', () {
|
||||
test('天气和温度能从拼在一起的那个字段拆回来', () {
|
||||
expect(splitWeatherText('晴 25℃'), (weather: '晴', temperature: '25℃'));
|
||||
expect(splitWeatherText('多云 18℃'), (weather: '多云', temperature: '18℃'));
|
||||
});
|
||||
|
||||
test('只有天气 / 只有温度 / 空,三种都要兜住', () {
|
||||
expect(splitWeatherText('晴'), (weather: '晴', temperature: ''));
|
||||
expect(splitWeatherText('25℃'), (weather: '', temperature: '25℃'));
|
||||
expect(splitWeatherText(''), (weather: '', temperature: ''));
|
||||
expect(splitWeatherText(' '), (weather: '', temperature: ''));
|
||||
});
|
||||
|
||||
test('★ 重新打水印时复用当时的地点和天气(不是你现在站的地方)', () {
|
||||
const photo = PhotoItem(
|
||||
id: 7,
|
||||
url: '/uploads/photo/a.jpg',
|
||||
thumbUrl: null,
|
||||
takenAt: '2026-09-28 14:30',
|
||||
address: '云南省昆明市五华区',
|
||||
weather: '晴 25℃',
|
||||
remark: '和宝一起',
|
||||
latitude: 25.0,
|
||||
longitude: 102.7,
|
||||
);
|
||||
|
||||
final data = photo.toWatermarkData();
|
||||
|
||||
expect(data.address, '云南省昆明市五华区');
|
||||
expect(data.weather, '晴');
|
||||
expect(data.temperature, '25℃');
|
||||
expect(data.remark, '和宝一起');
|
||||
expect(data.time, DateTime(2026, 9, 28, 14, 30));
|
||||
});
|
||||
|
||||
test('拍摄时间解析不出来时退回当前时间,不崩', () {
|
||||
const photo = PhotoItem(
|
||||
id: 1,
|
||||
url: '',
|
||||
thumbUrl: null,
|
||||
takenAt: '',
|
||||
address: '',
|
||||
weather: '',
|
||||
remark: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
);
|
||||
expect(photo.toWatermarkData().time, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('绘制', () {
|
||||
test('paintWatermark 在真实画布上不抛异常', () {
|
||||
final recorder = ui.PictureRecorder();
|
||||
|
||||
Reference in New Issue
Block a user