Files
ReelShort/test/playback/sprite_thumbnail_axis_test.dart

288 lines
8.7 KiB
Dart

// Author: fruit.com.cn
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart';
import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart';
const String _spriteVtt = '''
WEBVTT
00:00:00.000 --> 00:00:01.000
sprite_001.jpg#xywh=0,0,160,90
00:00:01.000 --> 00:00:02.000
sprite_001.jpg#xywh=160,0,160,90
00:00:02.000 --> 00:00:03.000
sprite_002.jpg#xywh=0,0,160,90
00:00:03.000 --> 00:00:04.000
sprite_003.jpg#xywh=0,0,160,90
''';
void main() {
test('should_parse_cue_times_xywh_and_multiple_sprite_files', () {
final axis = parseSpriteThumbnailVtt(
_spriteVtt,
vttUrl: 'https://cdn.test/transcoded/thumbnails.vtt?e=1&s=x',
);
expect(axis.cues, hasLength(4));
expect(axis.cues.first.start, Duration.zero);
expect(axis.cues.first.end, const Duration(seconds: 1));
expect(axis.cues.first.spriteFile, 'sprite_001.jpg');
expect(axis.cues.first.sourceRect, const Rect.fromLTWH(0, 0, 160, 90));
expect(axis.cues[1].sourceRect, const Rect.fromLTWH(160, 0, 160, 90));
expect(axis.cues[2].spriteFile, 'sprite_002.jpg');
expect(axis.cues[3].spriteFile, 'sprite_003.jpg');
expect(
axis.resolveSpriteUri(axis.cues[2].spriteFile).toString(),
'https://cdn.test/transcoded/sprite_002.jpg',
);
});
test('should_select_cue_and_use_nearest_when_position_is_out_of_range', () {
final cues = parseSpriteThumbnailVtt(
_spriteVtt,
vttUrl: 'https://cdn.test/thumbnails.vtt',
).cues;
expect(
selectSpriteThumbnailCue(cues, const Duration(milliseconds: 1500)),
same(cues[1]),
);
expect(
selectSpriteThumbnailCue(cues, const Duration(seconds: -1)),
same(cues[0]),
);
expect(
selectSpriteThumbnailCue(cues, const Duration(seconds: 9)),
same(cues[3]),
);
});
test('should_return_empty_axis_when_vtt_cannot_be_parsed', () {
final axis = parseSpriteThumbnailVtt(
'not-a-valid-vtt',
vttUrl: 'https://cdn.test/broken.vtt',
);
expect(axis.cues, isEmpty);
});
testWidgets('should_keep_one_sprite_and_reload_after_eviction', (
tester,
) async {
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
final service = SpriteThumbnailAxisService(
loader: loader,
cacheTtl: const Duration(minutes: 10),
);
addTearDown(service.dispose);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
await service.prepareAt(
'https://cdn.test/thumbnails.vtt',
const Duration(milliseconds: 2500),
);
await service.prepareAt(
'https://cdn.test/thumbnails.vtt',
const Duration(milliseconds: 3500),
);
expect(service.spriteCacheSize, 1);
expect(service.spriteCacheKeys, ['sprite_003.jpg']);
expect(loader.images[0].debugDisposed, isTrue);
expect(loader.images[1].debugDisposed, isTrue);
expect(loader.images[2].debugDisposed, isFalse);
expect(
service.cachedFrameAt(
'https://cdn.test/thumbnails.vtt',
const Duration(milliseconds: 3500),
),
isNotNull,
);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(service.spriteCacheSize, 1);
expect(service.spriteCacheKeys, ['sprite_001.jpg']);
expect(loader.images[2].debugDisposed, isTrue);
expect(loader.images[3].debugDisposed, isFalse);
expect(
service.cachedFrameAt('https://cdn.test/thumbnails.vtt', Duration.zero),
isNotNull,
);
});
testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async {
final loader = _FakeSpriteLoader(
vttResponses: const [SpriteThumbnailForbiddenException(), _spriteVtt],
);
final service = SpriteThumbnailAxisService(loader: loader);
addTearDown(service.dispose);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(loader.vttCalls, 2);
expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue);
});
testWidgets('should_keep_zero_frames_ready_across_neighbor_episodes', (
tester,
) async {
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
final service = SpriteThumbnailAxisService(
loader: loader,
spriteCacheCapacity: 6,
);
addTearDown(service.dispose);
const current = 'https://cdn.test/episode-1/thumbnails.vtt';
const previous = 'https://cdn.test/episode-0/thumbnails.vtt';
const next = 'https://cdn.test/episode-2/thumbnails.vtt';
await Future.wait([
service.prepareAt(previous, Duration.zero),
service.prepareAt(current, Duration.zero),
service.prepareAt(next, Duration.zero),
]);
for (final url in [previous, current, next]) {
expect(service.hasAxis(url), isTrue);
expect(service.cachedFrameAt(url, Duration.zero), isNotNull);
}
expect(loader.spriteCalls, hasLength(6));
});
testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async {
var now = DateTime(2026, 10, 22);
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
final service = SpriteThumbnailAxisService(loader: loader, now: () => now);
addTearDown(service.dispose);
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
now = now.add(const Duration(minutes: 11));
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
expect(loader.vttCalls, 2);
});
test('should_resolve_fallback_chain_in_priority_order', () {
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: true,
hasSpriteFrame: true,
hasAxis: true,
hasLocalFrame: true,
),
ScrubPreviewStage.sprite,
);
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: true,
hasSpriteFrame: false,
hasAxis: false,
hasLocalFrame: true,
),
ScrubPreviewStage.waitingForAxis,
);
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: false,
hasSpriteFrame: false,
hasAxis: false,
hasLocalFrame: true,
),
ScrubPreviewStage.localFrame,
);
expect(
decideScrubPreviewStage(
hasThumbnailsUrl: true,
hasSpriteFrame: false,
hasAxis: true,
hasLocalFrame: false,
),
ScrubPreviewStage.cover,
);
});
test('should_parse_thumbnails_url_from_episode_and_ticket', () {
final episode = Episode.fromJson(const {
'id': '11',
'number': 1,
'title': 'episode',
'isFree': true,
'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x',
'posterUrl': 'https://cdn.test/poster.jpg?e=1&s=x',
});
final ticket = PlayTicket.fromJson(const {
'episodeId': '11',
'accessible': true,
'accessType': 'FREE',
'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x',
'posterUrl': 'https://cdn.test/poster.jpg?e=1&s=x',
});
expect(episode.thumbnailsUrl, ticket.thumbnailsUrl);
expect(episode.posterUrl, ticket.posterUrl);
expect(
episode.withVideoUrl('file:///video.mp4').thumbnailsUrl,
episode.thumbnailsUrl,
);
expect(
episode.withVideoUrl('file:///video.mp4').posterUrl,
episode.posterUrl,
);
});
}
class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
_FakeSpriteLoader({this.vtt, this.vttResponses});
final String? vtt;
final List<Object>? vttResponses;
int vttCalls = 0;
final List<String> spriteCalls = [];
final List<ui.Image> images = [];
@override
Future<String?> loadVtt(Uri uri) async {
final index = vttCalls++;
final responses = vttResponses;
if (responses == null) return vtt;
final response = responses[index.clamp(0, responses.length - 1)];
if (response is SpriteThumbnailForbiddenException) throw response;
return response as String;
}
@override
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
spriteCalls.add(uri.pathSegments.last);
final image = await _createImage(320, 180);
images.add(image);
return SpriteThumbnailDecodedImage(
image: image,
originalWidth: 320,
originalHeight: 180,
);
}
}
Future<ui.Image> _createImage(int width, int height) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
Paint()..color = Colors.blue,
);
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
picture.dispose();
return image;
}