228 lines
7.3 KiB
Dart
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() {}
|
|
}
|