c94b9e5e58
Co-Authored-By: Claude Code <noreply@anthropic.com>
197 lines
5.9 KiB
Dart
197 lines
5.9 KiB
Dart
/// 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<void> 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<void>.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<Image>(find.byType(Image));
|
|
expect(image.image, isA<NetworkImage>());
|
|
} finally {
|
|
debugNetworkImageHttpClientProvider = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 以真实 1x1 PNG 字节应答全部 GET 的 HttpClient 替身。
|
|
class _FakeHttpClient implements HttpClient {
|
|
@override
|
|
bool autoUncompress = true;
|
|
|
|
@override
|
|
Future<HttpClientRequest> 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<HttpClientResponse> close() async => _FakeHttpClientResponse();
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) =>
|
|
throw UnimplementedError('${invocation.memberName}');
|
|
}
|
|
|
|
class _FakeHttpClientResponse extends Stream<List<int>>
|
|
implements HttpClientResponse {
|
|
late final Stream<List<int>> _stream = Stream<List<int>>.value(kPngBytes);
|
|
|
|
@override
|
|
StreamSubscription<List<int>> listen(
|
|
void Function(List<int> 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<Cookie> get cookies => <Cookie>[];
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) =>
|
|
throw UnimplementedError('${invocation.memberName}');
|
|
}
|
|
|
|
class _FakeHttpHeaders implements HttpHeaders {
|
|
final Map<String, String> _values = <String, String>{};
|
|
|
|
@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}');
|
|
}
|