/// PhotoThumbnail widget 测试(UI-01 Task 3)。 /// /// 覆盖:无 URL 常驻中性占位(固定宽高比)、加载中立即显示占位(非 /// 转圈,网格布局稳定)、失败替代图(中性块 + 小图标,不显示破图)、 /// 加载成功显示图片(HttpOverrides 注入 1x1 PNG)。 library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/app/theme.dart'; import 'package:photo_live/ui/components/photo_thumbnail.dart'; /// 1x1 透明 PNG(真实可解码字节)。 final Uint8List kPngBytes = base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ' 'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', ); void main() { Future pumpThumb( WidgetTester tester, { String? url, double aspectRatio = 16 / 9, }) { return tester.pumpWidget( MaterialApp( theme: AppTheme.light, home: Scaffold( body: Center( child: PhotoThumbnail(url: url, aspectRatio: aspectRatio), ), ), ), ); } testWidgets('url 为 null → 常驻中性占位(固定宽高比)', (tester) async { await pumpThumb(tester); await tester.pumpAndSettle(); expect(find.byIcon(Icons.image_outlined), findsOneWidget); expect(find.byIcon(Icons.broken_image_outlined), findsNothing); // 固定宽高比:视口 800x600 → 800 x 450。 final size = tester.getSize(find.byType(PhotoThumbnail)); expect(size.width, 800); expect(size.height, 450); }); testWidgets('加载中 → 立即占位;失败 → 中性块 + 小图标(不显示破图)', (tester) async { // 测试环境默认 HttpClient 一律 400 → NetworkImage 失败路径。 await pumpThumb(tester, url: 'http://test.local/cover.jpg'); // 首帧立即显示占位(非转圈;布局稳定)。 expect(find.byIcon(Icons.image_outlined), findsOneWidget); await tester.pumpAndSettle(); expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); expect(find.byIcon(Icons.image_outlined), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing); }); testWidgets('加载成功 → 图片显示、占位消失', (tester) async { // debug 钩子注入假 HttpClient:以真实 1x1 PNG 字节应答全部 GET // (覆盖 flutter_test 默认的网络 400 mock)。测试体内复位——painting // 调试变量须在框架不变量校验前归位(addTearDown 时机过晚)。 debugNetworkImageHttpClientProvider = _FakeHttpClient.new; try { await pumpThumb(tester, url: 'http://test.local/cover.png'); // 立即占位(帧未 ready)。 expect(find.byIcon(Icons.image_outlined), findsOneWidget); // 真实网络字节 + dart:ui 解码在真实事件循环中完成(fake-async 需要 // runAsync 放行)。 await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 200)), ); await tester.pumpAndSettle(); expect( find.byIcon(Icons.image_outlined), findsNothing, reason: '加载完成后占位消失', ); expect(find.byIcon(Icons.broken_image_outlined), findsNothing); final image = tester.widget(find.byType(Image)); expect(image.image, isA()); } finally { debugNetworkImageHttpClientProvider = null; } }); } /// 以真实 1x1 PNG 字节应答全部 GET 的 HttpClient 替身。 class _FakeHttpClient implements HttpClient { @override bool autoUncompress = true; @override Future getUrl(Uri url) async => _FakeHttpClientRequest(); @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError('${invocation.memberName}'); } class _FakeHttpClientRequest implements HttpClientRequest { final _FakeHttpHeaders _headers = _FakeHttpHeaders(); @override HttpHeaders get headers => _headers; @override String get method => 'GET'; @override Future close() async => _FakeHttpClientResponse(); @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError('${invocation.memberName}'); } class _FakeHttpClientResponse extends Stream> implements HttpClientResponse { late final Stream> _stream = Stream>.value(kPngBytes); @override StreamSubscription> listen( void Function(List event)? onData, { Function? onError, void Function()? onDone, bool? cancelOnError, }) => _stream.listen( onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError, ); @override int get statusCode => HttpStatus.ok; @override int get contentLength => kPngBytes.length; @override bool get isRedirect => false; @override bool get persistentConnection => true; @override String get reasonPhrase => 'OK'; @override HttpHeaders get headers => _FakeHttpHeaders(); @override HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed; @override List get cookies => []; @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError('${invocation.memberName}'); } class _FakeHttpHeaders implements HttpHeaders { final Map _values = {}; @override String? value(String field) => _values[field.toLowerCase()]; @override void add(String name, Object value, {bool preserveHeaderCase = false}) { _values[name.toLowerCase()] = value.toString(); } @override void set(String name, Object value, {bool preserveHeaderCase = false}) { _values[name.toLowerCase()] = value.toString(); } @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError('${invocation.memberName}'); }