import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as images; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_app/kyc/photo.dart'; import 'package:sxchnge_app/kyc/preview.dart'; import 'package:sxchnge_app/generated/i18n/app_localizations.dart'; Widget host(Widget child, {Locale locale = const Locale('en')}) => MaterialApp( locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: SingleChildScrollView(child: child)), ); // Start decoding outside FakeAsync as well as waiting outside it, including // browser codec callbacks that otherwise remain pending in the test zone. Future show(WidgetTester tester, Widget widget) async { await tester.runAsync(() => tester.pumpWidget(widget)); } // pumpAndSettle alone does not wait for image codecs. Listen to the actual // preview streams so a hidden errorBuilder cannot make this regression pass. Future decoded(WidgetTester tester, {bool succeeds = true}) async { final providers = tester .widgetList(find.byType(Image)) .map((image) => image.image) .toList(); expect(providers, isNotEmpty); for (final provider in providers) { Object? renderError; StackTrace? renderStack; await tester.runAsync(() async { final stream = provider.resolve(ImageConfiguration.empty); final ready = Completer(); final listener = ImageStreamListener( (info, _) { info.dispose(); if (!ready.isCompleted) ready.complete(); }, onError: (Object error, StackTrace? stack) { renderError = error; renderStack = stack; if (!ready.isCompleted) ready.complete(); }, ); stream.addListener(listener); try { await ready.future.timeout(const Duration(seconds: 15)); } finally { stream.removeListener(listener); } }); expect( renderError, succeeds ? isNull : isNotNull, reason: succeeds ? '$renderError\n$renderStack' : null, ); } await tester.pumpAndSettle(); expect(tester.takeException(), isNull); } void main() { late Uint8List png; late Uint8List jpeg; setUpAll(() { // Synthetic, non-sensitive portrait and landscape photos in both formats. png = images.encodePng(images.Image(width: 1200, height: 1600)); jpeg = images.encodeJpg(images.Image(width: 1600, height: 1200)); }); for (final format in ['PNG', 'JPEG']) { testWidgets('accepted $format renders from an immutable photo model', ( tester, ) async { final source = Uint8List.fromList(format == 'PNG' ? png : jpeg); final photo = (await tester.runAsync( () => FilePhotoService.validate(source), ))!; final originalFirstByte = photo.bytes.first; source[0] = 0; expect(photo.bytes.first, originalFirstByte); expect(() => photo.bytes[0] = 0, throwsUnsupportedError); await show( tester, host(PhotoPreview(photo: photo, label: 'ID document photo')), ); await decoded(tester); final raw = tester.widget(find.byType(RawImage)); expect(raw.image, isNotNull); expect(raw.image!.width, lessThanOrEqualTo(1000)); expect( tester.widget(find.byType(Image)).semanticLabel, 'ID document photo', ); expect(find.textContaining('damaged'), findsNothing); expect(find.textContaining('could not be displayed'), findsNothing); }); } testWidgets('replacement and removal evict sensitive preview cache entries', ( tester, ) async { final first = Photo(png); await show(tester, host(PhotoPreview(photo: first, label: 'ID'))); await decoded(tester); final initialProvider = tester.widget(find.byType(Image)).image; final initialKey = await initialProvider.obtainKey( ImageConfiguration.empty, ); final cache = PaintingBinding.instance.imageCache; expect(cache.statusForKey(initialKey).keepAlive, isTrue); await show(tester, host(PhotoPreview(photo: first, label: 'Document'))); await decoded(tester); expect( identical( tester.widget(find.byType(Image)).image, initialProvider, ), isTrue, ); await show(tester, host(PhotoPreview(photo: Photo(jpeg), label: 'ID'))); await decoded(tester); expect(cache.statusForKey(initialKey).untracked, isTrue); final replacement = tester.widget(find.byType(Image)).image; final replacementKey = await replacement.obtainKey( ImageConfiguration.empty, ); expect(cache.statusForKey(replacementKey).keepAlive, isTrue); expect(tester.widget(find.byType(RawImage)).image!.width, 1000); await show(tester, host(const SizedBox())); await tester.pumpAndSettle(); expect(cache.statusForKey(replacementKey).untracked, isTrue); }); testWidgets('ID and selfie both render on review and after returning', ( tester, ) async { final passport = Photo(png); final selfie = Photo(jpeg); for (var visit = 0; visit < 2; visit++) { await show( tester, host( Column( children: [ PhotoPanel( kind: PhotoKind.passport, photo: passport, review: true, ), PhotoPanel(kind: PhotoKind.selfie, photo: selfie, review: true), ], ), ), ); await decoded(tester); final previews = tester.widgetList(find.byType(RawImage)); expect(previews.length, 2); expect(previews.every((image) => image.image != null), isTrue); expect(find.text('Choose a different photo'), findsNothing); await show(tester, host(const SizedBox())); await tester.pumpAndSettle(); } }); for (final locale in ['en', 'ru']) { testWidgets( '$locale preview failure explains recovery and can be replaced', (tester) async { tester.view.physicalSize = const Size(320, 900); tester.view.devicePixelRatio = 1; tester.platformDispatcher.textScaleFactorTestValue = 1.6; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); var replacements = 0; var removals = 0; await show( tester, host( PhotoPanel( kind: PhotoKind.passport, photo: Photo(Uint8List.fromList([1, 2, 3])), onPick: () => replacements++, onRemove: () => removals++, ), locale: Locale(locale), ), ); await decoded(tester, succeeds: false); final l = AppLocalizations.of(tester.element(find.byType(PhotoPanel)))!; expect(find.text(l.kycErrorPreview), findsOneWidget); expect(find.text(l.kycErrorUnreadable), findsNothing); expect( tester.getSemantics(find.text(l.kycErrorPreview)), matchesSemantics(isLiveRegion: true, label: l.kycErrorPreview), ); final replace = find.byKey(const ValueKey('pick-passport')); await tester.ensureVisible(replace); await tester.tap(replace); final remove = find.text(l.kycRemove); await tester.ensureVisible(remove); await tester.tap(remove); expect(replacements, 1); expect(removals, 1); await show( tester, host( PhotoPanel(kind: PhotoKind.passport, photo: Photo(jpeg)), locale: Locale(locale), ), ); await decoded(tester); expect(find.text(l.kycErrorPreview), findsNothing); expect(tester.widget(find.byType(RawImage)).image, isNotNull); }, ); } }