import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:marianum_mobile/push/push_avatar.dart'; final Uint8List _fakeBytes = Uint8List.fromList([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, // ]); http.Response _imageResponse() => http.Response.bytes( _fakeBytes, 200, headers: {'content-type': 'image/png'}, ); /// Renders a solid 40×20 PNG via dart:ui — a real decodable source image. Future _generatePng() async { final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder); canvas.drawRect( const ui.Rect.fromLTWH(0, 0, 40, 20), ui.Paint()..color = const ui.Color(0xFF993333), ); final image = await recorder.endRecording().toImage(40, 20); final data = await image.toByteData(format: ui.ImageByteFormat.png); return data!.buffer.asUint8List(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; setUp(() async { tempDir = await Directory.systemTemp.createTemp('push_avatar_test'); }); tearDown(() async { await tempDir.delete(recursive: true); }); PushAvatarStore store({ required Future Function(String chatToken) fetch, Duration timeout = const Duration(seconds: 4), }) => PushAvatarStore( cacheDirProvider: () async => tempDir, fetch: fetch, fetchTimeout: timeout, ); group('maskAvatarCircular', () { test('output is a square PNG sized to the shorter edge', () async { final source = await _generatePng(); final masked = await maskAvatarCircular(source); expect(masked, isNotNull); // PNG magic bytes. expect(masked!.sublist(0, 8), [ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // ]); final codec = await ui.instantiateImageCodec(masked); final frame = await codec.getNextFrame(); expect(frame.image.width, 20); expect(frame.image.height, 20); }); test('is deterministic for identical input', () async { final source = await _generatePng(); final a = await maskAvatarCircular(source); final b = await maskAvatarCircular(source); expect(a, isNotNull); expect(a, equals(b)); }); test('undecodable bytes yield null (caller falls back to raw)', () async { expect(await maskAvatarCircular(_fakeBytes), isNull); }); }); group('roomAvatarIcon', () { test('fetches, caches, and serves from cache afterwards', () async { var fetchCount = 0; final s = store( fetch: (_) async { fetchCount++; return _imageResponse(); }, ); final first = await s.roomAvatarIcon('iconToken1'); // Fake bytes are undecodable → mask falls back to the raw bytes. expect(first.icon, _fakeBytes); expect(first.late, isNull); expect(fetchCount, 1); final second = await s.roomAvatarIcon('iconToken1'); expect(second.icon, _fakeBytes); expect(fetchCount, 1); }); test('svg placeholder is rejected and not cached', () async { final s = store( fetch: (_) async => http.Response( '', 200, headers: {'content-type': 'image/svg+xml'}, ), ); final lookup = await s.roomAvatarIcon('iconToken2'); expect(lookup.icon, isNull); expect(lookup.late, isNull); expect(File('${tempDir.path}/iconToken2').existsSync(), isFalse); }); test('definitive fetch error yields no icon and no late future', () async { final s = store(fetch: (_) async => throw const SocketException('down')); final lookup = await s.roomAvatarIcon('iconToken3'); expect(lookup.icon, isNull); expect(lookup.late, isNull); }); test('fetch error falls back to a stale cache entry', () async { final file = File('${tempDir.path}/iconToken4'); await file.writeAsBytes(_fakeBytes); await file.setLastModified( DateTime.now().subtract(const Duration(days: 20)), ); final s = store(fetch: (_) async => throw const SocketException('down')); final lookup = await s.roomAvatarIcon('iconToken4'); expect(lookup.icon, _fakeBytes); }); test( 'timeout returns a late future that delivers exactly one result', () async { final completer = Completer(); var fetchCount = 0; final s = store( fetch: (_) { fetchCount++; return completer.future; }, timeout: const Duration(milliseconds: 50), ); final lookup = await s.roomAvatarIcon('iconToken5'); expect(lookup.icon, isNull); expect(lookup.late, isNotNull); completer.complete(_imageResponse()); final lateBytes = await lookup.late; expect(lateBytes, _fakeBytes); // The processed cache now serves directly — no second fetch. final second = await s.roomAvatarIcon('iconToken5'); expect(second.icon, _fakeBytes); expect(second.late, isNull); expect(fetchCount, 1); }, ); test('non-200 responses yield null', () async { final s = store(fetch: (_) async => http.Response('gone', 404)); final lookup = await s.roomAvatarIcon('iconToken6'); expect(lookup.icon, isNull); expect(lookup.late, isNull); }); }); group('fileNameForToken / isFresh / looksLikeSvg', () { test('url-safe tokens are used verbatim', () { expect(PushAvatarStore.fileNameForToken('abc123_XY-z'), 'abc123_XY-z'); }); test('unsafe tokens are encoded deterministically and stay distinct', () { final a = PushAvatarStore.fileNameForToken('a/b'); expect(a, PushAvatarStore.fileNameForToken('a/b')); expect(a, isNot(PushAvatarStore.fileNameForToken('a.b'))); expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(a), isTrue); }); test('isFresh boundary', () { final now = DateTime(2026, 7, 5, 12); expect( PushAvatarStore.isFresh(now.subtract(const Duration(days: 13)), now), isTrue, ); expect( PushAvatarStore.isFresh( now.subtract(const Duration(days: 14, hours: 1)), now, ), isFalse, ); }); test('svg detection', () { expect( PushAvatarStore.looksLikeSvg( Uint8List.fromList(' '.codeUnits), ), isTrue, ); expect(PushAvatarStore.looksLikeSvg(_fakeBytes), isFalse); }); }); }