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/photo.dart'; 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 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('天气和温度能从拼在一起的那个字段拆回来', () { 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(); 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); }); }); }