feat(app): crop cover & banner before upload (crop_your_image, 16:9 / 3:1)

review fixes: drop GIF from pre-sniff (package maps gif→png decode → never-ready hang); PopScope blocks back during processing

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 15:39:39 +08:00
parent 97db504e25
commit 683e19bdc1
10 changed files with 632 additions and 7 deletions
@@ -0,0 +1,208 @@
/// 封面/宣传图裁剪页 widget 测试(crop_your_image 回调式 API)。
///
/// 真实异步边界(源文件读取 + 包内 parse/crop 的 compute + 编码落盘
/// Isolate.run)在 testWidgets 的 FakeAsync 下不会自行推进——参照
/// crop_your_image 自身测试的驱动方式:tap + 等待都包进 tester.runAsync,
/// 用真实 Future.delayed 等 isolate 完成。
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/image_crop_page.dart';
void main() {
late final Directory tmp;
late final String fixtureJpg;
late final String fixtureGif;
setUpAll(() {
tmp = Directory.systemTemp.createTempSync('image_crop_page_test');
// 320x240(4:3)JPG 夹具:运行时生成,避免仓库二进制资产。
final source = img.Image(width: 320, height: 240, numChannels: 3);
img.fill(source, color: img.ColorRgb8(80, 120, 200));
fixtureJpg = '${tmp.path}${Platform.pathSeparator}fixture.jpg';
File(fixtureJpg).writeAsBytesSync(img.encodeJpg(source, quality: 90));
// GIF 夹具:包内 formatDetector 兜底按 png 解码必然失败挂起,
// 页面预检须直接判不支持(防 never-ready)。
fixtureGif = '${tmp.path}${Platform.pathSeparator}fixture.gif';
File(fixtureGif).writeAsBytesSync(img.encodeGif(source));
});
tearDownAll(() {
tmp.deleteSync(recursive: true);
});
/// 打开裁剪页(zh 本地化);onPopped 接住 pop 结果。
Future<void> openCropPage(
WidgetTester tester,
String sourcePath, {
required double aspectRatio,
int? maxOutputWidth,
void Function(String? value)? onPopped,
}) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: FloatingActionButton(
onPressed: () async {
final value = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => ImageCropPage(
sourcePath: sourcePath,
aspectRatio: aspectRatio,
maxOutputWidth: maxOutputWidth,
),
),
);
onPopped?.call(value);
},
),
),
),
),
),
);
await tester.runAsync(() async {
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// 真实异步必须「等→pump→再等→再 pump」:读取完成后经 pump 才构建
// Crop(parse compute 此刻才启动),需再等真实时间让 isolate 回复落地。
await Future.delayed(const Duration(seconds: 2));
await tester.pump();
await Future.delayed(const Duration(seconds: 2));
await tester.pump();
});
await tester.pump();
}
testWidgets('取消 → pop(null)', (tester) async {
String? popped = 'sentinel';
await openCropPage(
tester,
fixtureJpg,
aspectRatio: 16 / 9,
onPopped: (value) => popped = value,
);
await tester.runAsync(() async {
await tester.tap(find.text('取消'));
await tester.pump();
});
await tester.pumpAndSettle();
expect(popped, isNull);
});
testWidgets('确认 → pop 出存在的 .jpg 路径', (tester) async {
String? popped;
await openCropPage(
tester,
fixtureJpg,
aspectRatio: 16 / 9,
maxOutputWidth: 1920,
onPopped: (value) => popped = value,
);
await tester.runAsync(() async {
await tester.tap(find.text('完成'));
await tester.pump();
// crop(compute)→ 编码落盘(Isolate.run)→ pop。
await Future.delayed(const Duration(seconds: 5));
await tester.pump();
});
await tester.pumpAndSettle();
expect(popped, isNotNull);
expect(popped!.endsWith('.jpg'), isTrue);
expect(File(popped!).existsSync(), isTrue);
});
testWidgets('确认 → 输出尊重 maxOutputWidth 且保持纵横比', (tester) async {
String? popped;
await openCropPage(
tester,
fixtureJpg,
aspectRatio: 16 / 9,
maxOutputWidth: 160,
onPopped: (value) => popped = value,
);
await tester.runAsync(() async {
await tester.tap(find.text('完成'));
await tester.pump();
await Future.delayed(const Duration(seconds: 5));
await tester.pump();
});
await tester.pumpAndSettle();
expect(popped, isNotNull);
final decoded = img.decodeImage(File(popped!).readAsBytesSync());
expect(decoded, isNotNull);
expect(decoded!.width, 160);
expect(decoded.height, 90);
});
testWidgets('非图片源 → 提示不支持且不 pop', (tester) async {
final bogus = File('${tmp.path}${Platform.pathSeparator}bogus.jpg');
bogus.writeAsStringSync('definitely not an image');
final popped = <String?>[];
await openCropPage(
tester,
bogus.path,
aspectRatio: 3 / 1,
onPopped: popped.add,
);
await tester.pump();
expect(find.text('暂不支持该图片格式,请选择 JPG/PNG'), findsOneWidget);
expect(popped, isEmpty);
// 完成不可用(无裁剪可做)。
final done = find.text('完成');
expect(
tester
.widget<FilledButton>(
find.ancestor(of: done, matching: find.byType(FilledButton)),
)
.enabled,
isFalse,
);
bogus.deleteSync();
});
testWidgets('GIF 源 → 预检判不支持(防包内 png 兜底解码挂起)且不 pop',
(tester) async {
final popped = <String?>[];
await openCropPage(
tester,
fixtureGif,
aspectRatio: 16 / 9,
onPopped: popped.add,
);
await tester.pump();
expect(find.text('暂不支持该图片格式,请选择 JPG/PNG'), findsOneWidget);
expect(popped, isEmpty);
// 完成不可用:包内若按 png 兜底解析 GIF,将永远到不了 ready。
final done = find.text('完成');
expect(
tester
.widget<FilledButton>(
find.ancestor(of: done, matching: find.byType(FilledButton)),
)
.enabled,
isFalse,
);
});
}