212 lines
6.5 KiB
Dart
212 lines
6.5 KiB
Dart
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<Uint8List> _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<http.Response> 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(
|
||
'<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||
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<http.Response>();
|
||
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(' <svg xmlns="..."/>'.codeUnits),
|
||
),
|
||
isTrue,
|
||
);
|
||
expect(PushAvatarStore.looksLikeSvg(_fakeBytes), isFalse);
|
||
});
|
||
});
|
||
}
|