// Author: fruit.com.cn import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart'; void main() { test('should_bucket_thumbnail_requests_by_whole_seconds', () { expect(playbackThumbnailBucket(Duration.zero), 0); expect(playbackThumbnailBucket(const Duration(milliseconds: 1999)), 1); expect(playbackThumbnailBucket(const Duration(seconds: 12)), 12); expect(playbackThumbnailBucket(const Duration(milliseconds: -300)), 0); }); test('should_return_injected_frame_from_loader', () async { final loader = _RecordingLoader((_) async => ByteData(1)); final frame = await loader.load( const PlaybackThumbnailFrameRequest( episodeId: 'episode', position: Duration(seconds: 1), source: PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), ), ); expect(frame, isNotNull); }); test('should_evict_least_recently_used_frame_after_24_entries', () async { final loader = _RecordingLoader((_) async => ByteData(1)); final service = _service(loader); addTearDown(service.dispose); for (var second = 0; second < 25; second++) { final frame = await service.thumbnail( 'episode', Duration(seconds: second), ); expect( frame, isNotNull, reason: 'bucket $second loader=${loader.buckets}', ); } expect(service.cacheSize, 24); expect(loader.buckets.first, 0); await service.thumbnail('episode', const Duration(seconds: 1)); expect(loader.buckets.where((bucket) => bucket == 1), hasLength(1)); await service.thumbnail('episode', Duration.zero); expect(loader.buckets.where((bucket) => bucket == 0), hasLength(2)); }); test('should_coalesce_same_bucket_while_frame_is_in_flight', () async { final frame = Completer(); final loader = _RecordingLoader((_) => frame.future); final service = _service(loader); addTearDown(service.dispose); final first = service.thumbnail('episode', const Duration(seconds: 8)); await Future.delayed(Duration.zero); final second = service.thumbnail( 'episode', const Duration(milliseconds: 8999), ); expect(identical(first, second), isTrue); expect(loader.buckets, [8]); frame.complete(ByteData(2)); expect(await first, isNotNull); expect(await second, isNotNull); }); test('should_drop_superseded_request_and_start_latest_bucket', () async { final frames = >{}; final loader = _RecordingLoader((request) { final completer = Completer(); frames[request.position.inSeconds] = completer; return completer.future; }); final service = _service(loader); addTearDown(service.dispose); final stale = service.thumbnail('episode', const Duration(seconds: 4)); await Future.delayed(Duration.zero); final latest = service.thumbnail('episode', const Duration(seconds: 9)); await Future.delayed(Duration.zero); expect(loader.buckets, [4]); frames[4]!.complete(ByteData(4)); expect(await stale, isNull); await Future.delayed(Duration.zero); expect(loader.buckets, [4, 9]); frames[9]!.complete(ByteData(9)); expect(await latest, isNotNull); }); test('should_cancel_unstarted_request_when_scrub_ends', () async { final firstFrame = Completer(); final loader = _RecordingLoader((request) { if (request.position.inSeconds == 3) return firstFrame.future; return Future.value(ByteData(7)); }); final service = _service(loader); addTearDown(service.dispose); final active = service.thumbnail('episode', const Duration(seconds: 3)); await Future.delayed(Duration.zero); final pending = service.thumbnail('episode', const Duration(seconds: 7)); service.cancelPending(); expect(await pending, isNull); firstFrame.complete(ByteData(3)); expect(await active, isNull); await Future.delayed(Duration.zero); expect(loader.buckets, [3]); }); test('should_prefetch_one_neighbor_after_scrub_pause', () async { final loader = _RecordingLoader((_) async => ByteData(1)); final service = _service( loader, prefetchDelay: const Duration(milliseconds: 10), ); addTearDown(service.dispose); await service.thumbnail('episode', const Duration(seconds: 5)); expect(loader.buckets, [5]); await Future.delayed(const Duration(milliseconds: 5)); expect(loader.buckets, [5]); await Future.delayed(const Duration(milliseconds: 12)); expect(loader.buckets, [5, 6]); }); test('should_not_prefetch_over_newer_unstarted_bucket', () async { final frames = >{}; final loader = _RecordingLoader((request) { final completer = Completer(); frames[request.position.inSeconds] = completer; return completer.future; }); final service = _service( loader, prefetchDelay: const Duration(milliseconds: 10), ); addTearDown(service.dispose); final first = service.thumbnail('episode', const Duration(seconds: 1)); await Future.delayed(Duration.zero); final latest = service.thumbnail('episode', const Duration(seconds: 3)); await Future.delayed(const Duration(milliseconds: 20)); expect(loader.buckets, [1]); frames[1]!.complete(ByteData(1)); expect(await first, isNull); await Future.delayed(Duration.zero); expect(loader.buckets, [1, 3]); frames[3]!.complete(ByteData(3)); expect(await latest, isNotNull); }); test('should_return_null_once_then_negative_cache_failed_bucket', () async { final loader = _RecordingLoader((_) => Completer().future); final service = _service( loader, requestTimeout: const Duration(milliseconds: 10), ); addTearDown(service.dispose); expect( await service.thumbnail('episode', const Duration(seconds: 2)), isNull, ); expect( await service.thumbnail('episode', const Duration(seconds: 2)), isNull, ); expect(loader.buckets, [2]); }); test('should_convert_loader_exception_to_null', () async { final loader = _RecordingLoader((_) async => throw StateError('decode')); final service = _service(loader); addTearDown(service.dispose); expect( await service.thumbnail('episode', const Duration(seconds: 6)), isNull, ); }); } PlaybackThumbnailService _service( PlaybackThumbnailFrameLoader loader, { Duration requestTimeout = const Duration(hours: 1), Duration prefetchDelay = const Duration(hours: 1), }) { return PlaybackThumbnailService( frameLoader: loader, sourceResolver: (_) => const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), requestTimeout: requestTimeout, prefetchDelay: prefetchDelay, ); } class _RecordingLoader implements PlaybackThumbnailFrameLoader { _RecordingLoader(this._handler); final Future Function(PlaybackThumbnailFrameRequest request) _handler; final List buckets = []; @override Future load(PlaybackThumbnailFrameRequest request) { buckets.add(request.position.inSeconds); return _handler(request); } @override void dispose() {} }