水印相机前端
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user