水印相机前端

This commit is contained in:
2026-09-28 09:59:43 +08:00
parent 187a7e0911
commit c5b3d81d07
19 changed files with 2761 additions and 3 deletions
+167
View File
@@ -0,0 +1,167 @@
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:qiangbao_accounting/api/geo_api.dart';
import 'package:qiangbao_accounting/api/photo_api.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: '没有定位权限');
}
/// 假上传:立刻成功,避免测试里真的打网络
class _FakePhotoApi extends PhotoApi {
_FakePhotoApi() : super(Dio());
@override
Future<int?> upload({
required String filePath,
double? latitude,
double? longitude,
String? address,
String? weather,
String? remark,
DateTime? takenTime,
}) async =>
1;
}
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();
}
for (final (label, size) in sizes) {
testWidgets('入口页 $label 不溢出', (tester) async {
useSize(tester, size);
await tester.pumpWidget(
const ProviderScope(child: 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(
const ProviderScope(child: 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));
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);
});
}
+79
View File
@@ -0,0 +1,79 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:qiangbao_accounting/models/watermark.dart';
import 'package:qiangbao_accounting/pages/watermark/watermark_painter.dart';
/// 把水印渲染到一张模拟背景上并**写出图片文件**,用来用眼睛验收排版。
///
/// 为什么需要它:单元测试只能断言「不报错、尺寸对」,断言不了「好不好看」。
/// 之前正是靠看图才发现「时间:2026.09.28 14:30」是 19 个字、被折行阈值切成
/// 两行、最后一个「0」孤零零占了一行。
///
/// 跑法:
/// ```
/// flutter test test/watermark_preview_test.dart
/// ```
/// 输出图片的路径会打印在结果里,直接用图片查看器打开即可。
///
/// 注意:测试环境没有中文字体,汉字会渲染成方块(Ahem 测试字体)——
/// **这是正常的**,看的是排版/位置/颜色,不是字形。真机上才是真汉字。
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// 造一张「有明有暗」的底图,用来检查白字在浅色区域会不会糊
img.Image makeBackground(int w, int h) {
final bg = img.Image(width: w, height: h);
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
final t = x / w;
var v = (215 - 150 * t).round(); // 左亮右暗
if (y > h * 0.55 && y < h * 0.62) v = 235; // 一条浅色横带,模拟高光
bg.setPixelRgb(x, y, v, (v * 0.96).round(), (v * 0.9).round());
}
}
return bg;
}
test('渲染一张水印预览图供人工核对', () async {
final iconData = await rootBundle.load('assets/images/watermark_camera.png');
final codec = await ui.instantiateImageCodec(iconData.buffer.asUint8List());
final brandIcon = (await codec.getNextFrame()).image;
// 全项打开,把四种行和品牌角标都覆盖到
final settings = WatermarkSettings.defaults.copyWith(
showWeather: true,
showRemark: true,
);
final data = WatermarkData(
time: DateTime(2026, 9, 28, 14, 30),
address: '云南省昆明市五华区莲花池正街某小区 3 栋',
weather: '晴',
temperature: '25℃',
remark: '和宝一起吃的那家店',
);
final jpg = await composeWatermarkedJpeg(
originalBytes: img.encodePng(makeBackground(1080, 1440)),
settings: settings,
data: data,
brandIcon: brandIcon,
maxWidth: 1080,
);
// 顺带当一次端到端冒烟:产物必须是能解回来的合法 JPEG
final decoded = img.decodeJpg(jpg);
expect(decoded, isNotNull);
expect(decoded!.width, 1080);
expect(decoded.height, 1440);
final out = File('${Directory.systemTemp.path}/wm_preview.jpg')
..writeAsBytesSync(jpg);
// ignore: avoid_print
print('水印预览图已写出(人工核对用): ${out.path}');
});
}
+293
View File
@@ -0,0 +1,293 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:qiangbao_accounting/models/watermark.dart';
import 'package:qiangbao_accounting/pages/watermark/watermark_painter.dart';
/// 水印模块的核心逻辑测试。
///
/// 这里最值钱的一条是「不同画布尺寸下布局等比缩放」——
/// **预览和导出共用同一个 [paintWatermark],正是靠这个性质才做到所见即所得。**
/// 它一旦被破坏,用户就会遇到「预览挺好,导出来位置/大小不对」。
void main() {
// TextPainter 需要绑定初始化才能量文字
TestWidgetsFlutterBinding.ensureInitialized();
final sampleData = WatermarkData(
time: DateTime(2026, 9, 28, 14, 30),
address: '云南省昆明市五华区莲花池正街',
weather: '晴',
temperature: '25℃',
remark: '测试备注',
);
group('时间格式化', () {
test('补零到 2026.09.28 14:30 这种样子', () {
expect(formatWatermarkTime(DateTime(2026, 9, 28, 14, 30)), '2026.09.28 14:30');
expect(formatWatermarkTime(DateTime(2026, 1, 5, 9, 7)), '2026.01.05 09:07');
expect(formatWatermarkTime(DateTime(2026, 12, 31, 23, 59)), '2026.12.31 23:59');
});
});
group('水印正文拼装', () {
/// 取文本,忽略折行标记
List<String> texts(WatermarkSettings s, WatermarkData d) =>
buildWatermarkLines(s, d).map((l) => l.text).toList();
test('默认设置只出时间和地点', () {
expect(texts(WatermarkSettings.defaults, sampleData),
['时间:2026.09.28 14:30', '地点:云南省昆明市五华区莲花池正街']);
});
test('关掉的项不产生行', () {
final settings = WatermarkSettings.defaults.copyWith(
showTime: false,
showAddress: false,
);
expect(texts(settings, sampleData), isEmpty);
});
test('要求显示天气但拿不到天气时,不留下空行', () {
final settings = WatermarkSettings.defaults.copyWith(showWeather: true);
final noWeather = sampleData.copyWith(weather: '', temperature: '');
expect(
texts(settings, noWeather).any((l) => l.startsWith('天气:')),
isFalse,
);
});
test('有天气时拼成「天气:晴 25℃」', () {
final settings = WatermarkSettings.defaults.copyWith(showWeather: true);
expect(texts(settings, sampleData), contains('天气:晴 25℃'));
});
test('备注为空时不出行,有内容时去掉首尾空白', () {
final settings = WatermarkSettings.defaults.copyWith(showRemark: true);
expect(texts(settings, sampleData), contains('备注:测试备注'));
final blank = sampleData.copyWith(remark: ' ');
expect(
texts(settings, blank).any((l) => l.startsWith('备注:')),
isFalse,
);
});
test('地址拿不到时给「未知地点」占位,而不是留个空壳', () {
final lines = texts(WatermarkSettings.defaults, sampleData.copyWith(address: ''));
expect(lines, contains('地点:未知地点'));
});
test('时间行标记为「不折行」,其余行允许折', () {
final settings = WatermarkSettings.defaults.copyWith(showRemark: true);
final lines = buildWatermarkLines(settings, sampleData);
expect(lines.first.wrap, isFalse, reason: '时间行不能折');
expect(
lines.first.text.length,
greaterThan(18),
reason: '它正好 19 个字,超过折行阈值 —— 所以才必须豁免,否则会甩出一个孤字',
);
expect(lines.skip(1).every((l) => l.wrap), isTrue);
});
});
group('设置序列化', () {
test('存活一圈 JSON 还原后内容不变', () {
const original = WatermarkSettings(
showTime: false,
showAddress: true,
showWeather: true,
showRemark: true,
showBrand: false,
colorValue: 0xFF000000,
posX: 0.25,
posY: 0.75,
);
final restored = WatermarkSettings.fromJson(original.toJson());
expect(restored.showTime, isFalse);
expect(restored.showAddress, isTrue);
expect(restored.showWeather, isTrue);
expect(restored.showRemark, isTrue);
expect(restored.showBrand, isFalse);
expect(restored.colorValue, 0xFF000000);
expect(restored.posX, 0.25);
expect(restored.posY, 0.75);
});
test('脏数据不会把设置读崩,缺字段走默认值', () {
final restored = WatermarkSettings.fromJson(const {});
expect(restored.showTime, WatermarkSettings.defaults.showTime);
expect(restored.showAddress, WatermarkSettings.defaults.showAddress);
expect(restored.colorValue, WatermarkSettings.defaults.colorValue);
expect(restored.posX, isNull);
});
test('copyWith 能把位置显式置回 null(重置位置要用)', () {
const moved = WatermarkSettings(posX: 0.3, posY: 0.4);
expect(moved.copyWith().posX, 0.3, reason: '不传参数时保持不变');
expect(moved.copyWith(posX: null).posX, isNull, reason: '显式置 null 要生效');
expect(moved.copyWith(posX: null).posY, 0.4, reason: '另一个轴不受影响');
});
});
group('布局', () {
test('★ 时间行不会被折成两行(曾经把最后一个数字甩出去)', () {
// 「时间:2026.09.28 14:30」是 19 个字,而折行阈值是 18。
// 统一折行的话最后一个「0」会单独占一行 —— 这是导出预览图上真看到的。
final layout = layoutWatermark(
const Size(1080, 1440),
WatermarkSettings.defaults,
sampleData.copyWith(address: '昆明'),
);
expect(layout.lines.length, 2, reason: '时间 + 地点,正好两行');
expect(layout.lines.first, '时间:2026.09.28 14:30');
});
test('没拖过时默认贴左下角', () {
final layout = layoutWatermark(
const Size(1080, 1440),
WatermarkSettings.defaults,
sampleData,
);
// 左边距 28(设计稿单位),1080 宽时 scale = 1
expect(layout.origin.dx, closeTo(28, 0.01));
// 底边也留 28
expect(
layout.origin.dy + layout.cardHeight,
closeTo(1440 - 28, 0.5),
);
});
test('拖过之后用归一化坐标', () {
const settings = WatermarkSettings(posX: 0.5, posY: 0.25);
final layout = layoutWatermark(const Size(1080, 1440), settings, sampleData);
expect(layout.origin.dx, closeTo(540, 0.01));
expect(layout.origin.dy, closeTo(360, 0.01));
});
test('★ 等比缩放的画布,布局等比放大(预览=导出的前提)', () {
const settings = WatermarkSettings();
final small = layoutWatermark(const Size(540, 720), settings, sampleData);
final big = layoutWatermark(const Size(1080, 1440), settings, sampleData);
expect(big.scale, closeTo(small.scale * 2, 1e-9));
expect(big.textHeight, closeTo(small.textHeight * 2, 0.01));
expect(big.cardHeight, closeTo(small.cardHeight * 2, 0.01));
expect(big.cardWidth, closeTo(small.cardWidth * 2, 0.01));
expect(big.origin.dx, closeTo(small.origin.dx * 2, 0.01));
expect(big.origin.dy, closeTo(small.origin.dy * 2, 0.01));
});
test('长地址会折行,块高随之增加', () {
final short = layoutWatermark(
const Size(1080, 1440),
WatermarkSettings.defaults,
sampleData.copyWith(address: '昆明'),
);
final long = layoutWatermark(
const Size(1080, 1440),
WatermarkSettings.defaults,
sampleData.copyWith(address: '云南省昆明市五华区莲花池正街某小区某栋某单元某室'),
);
expect(long.lines.length, greaterThan(short.lines.length));
expect(long.cardHeight, greaterThan(short.cardHeight));
});
test('内容全关时布局为空(拖也没得拖)', () {
final layout = layoutWatermark(
const Size(1080, 1440),
WatermarkSettings.defaults.copyWith(
showTime: false,
showAddress: false,
),
sampleData,
);
expect(layout.isEmpty, isTrue);
expect(layout.cardHeight, 0);
});
});
group('绘制', () {
test('paintWatermark 在真实画布上不抛异常', () {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
paintWatermark(canvas, const Size(1080, 1440), WatermarkSettings.defaults, sampleData);
recorder.endRecording().dispose();
});
test('内容全关 + 关掉品牌角标时也不会崩', () {
final settings = WatermarkSettings.defaults.copyWith(
showTime: false,
showAddress: false,
showBrand: false,
);
final recorder = ui.PictureRecorder();
paintWatermark(Canvas(recorder), const Size(1080, 1440), settings, sampleData);
recorder.endRecording().dispose();
});
test('尺寸为 0 时直接返回,不做无意义绘制', () {
final recorder = ui.PictureRecorder();
paintWatermark(Canvas(recorder), Size.zero, WatermarkSettings.defaults, sampleData);
recorder.endRecording().dispose();
});
});
group('导出(整条离屏管线)', () {
// 这是风险最高的一段:解码 → 离屏绘制 → 栅格化 → JPEG 编码,
// 中间任何一环 API 用错都会到真机上才炸,所以这里端到端跑一遍。
test('合成出来的字节是合法 JPEG,尺寸不变', () async {
final source = img.encodePng(img.Image(width: 200, height: 150));
final bytes = await composeWatermarkedJpeg(
originalBytes: source,
settings: WatermarkSettings.defaults,
data: sampleData,
);
// JPEG 魔数 FF D8
expect(bytes.length, greaterThan(200));
expect(bytes[0], 0xFF);
expect(bytes[1], 0xD8);
final decoded = img.decodeJpg(bytes);
expect(decoded, isNotNull);
expect(decoded!.width, 200);
expect(decoded.height, 150);
});
test('超过 maxWidth 的图会被等比缩下来(省内存 + 让编码别太慢)', () async {
final source = img.encodePng(img.Image(width: 800, height: 600));
final bytes = await composeWatermarkedJpeg(
originalBytes: source,
settings: WatermarkSettings.defaults,
data: sampleData,
maxWidth: 400,
);
final decoded = img.decodeJpg(bytes);
expect(decoded!.width, 400);
expect(decoded.height, 300);
});
test('原始图比 maxWidth 小的时候不会被放大', () async {
final source = img.encodePng(img.Image(width: 120, height: 90));
final bytes = await composeWatermarkedJpeg(
originalBytes: source,
settings: WatermarkSettings.defaults,
data: sampleData,
maxWidth: 1920,
);
final decoded = img.decodeJpg(bytes);
expect(decoded!.width, 120);
expect(decoded.height, 90);
});
});
}