Files
ReelShort/test/playback/playback_thumbnail_service_test.dart
T

228 lines
7.3 KiB
Dart

// 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<ByteData?>();
final loader = _RecordingLoader((_) => frame.future);
final service = _service(loader);
addTearDown(service.dispose);
final first = service.thumbnail('episode', const Duration(seconds: 8));
await Future<void>.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 = <int, Completer<ByteData?>>{};
final loader = _RecordingLoader((request) {
final completer = Completer<ByteData?>();
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<void>.delayed(Duration.zero);
final latest = service.thumbnail('episode', const Duration(seconds: 9));
await Future<void>.delayed(Duration.zero);
expect(loader.buckets, [4]);
frames[4]!.complete(ByteData(4));
expect(await stale, isNull);
await Future<void>.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<ByteData?>();
final loader = _RecordingLoader((request) {
if (request.position.inSeconds == 3) return firstFrame.future;
return Future<ByteData?>.value(ByteData(7));
});
final service = _service(loader);
addTearDown(service.dispose);
final active = service.thumbnail('episode', const Duration(seconds: 3));
await Future<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 5));
expect(loader.buckets, [5]);
await Future<void>.delayed(const Duration(milliseconds: 12));
expect(loader.buckets, [5, 6]);
});
test('should_not_prefetch_over_newer_unstarted_bucket', () async {
final frames = <int, Completer<ByteData?>>{};
final loader = _RecordingLoader((request) {
final completer = Completer<ByteData?>();
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<void>.delayed(Duration.zero);
final latest = service.thumbnail('episode', const Duration(seconds: 3));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(loader.buckets, [1]);
frames[1]!.complete(ByteData(1));
expect(await first, isNull);
await Future<void>.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<ByteData?>().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<ByteData?> Function(PlaybackThumbnailFrameRequest request)
_handler;
final List<int> buckets = [];
@override
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) {
buckets.add(request.position.inSeconds);
return _handler(request);
}
@override
void dispose() {}
}