234 lines
6.9 KiB
Dart
234 lines
6.9 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_only_two_decoded_sprites_in_lru', (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);
|
|
expect(service.spriteCacheSize, 2);
|
|
expect(
|
|
service.spriteCacheKeys,
|
|
containsAll(['sprite_001.jpg', 'sprite_002.jpg']),
|
|
);
|
|
|
|
await service.prepareAt(
|
|
'https://cdn.test/thumbnails.vtt',
|
|
const Duration(milliseconds: 2500),
|
|
);
|
|
|
|
expect(service.spriteCacheSize, 2);
|
|
expect(service.spriteCacheKeys, contains('sprite_002.jpg'));
|
|
expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg')));
|
|
});
|
|
|
|
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_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',
|
|
});
|
|
final ticket = PlayTicket.fromJson(const {
|
|
'episodeId': '11',
|
|
'accessible': true,
|
|
'accessType': 'FREE',
|
|
'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x',
|
|
});
|
|
|
|
expect(episode.thumbnailsUrl, ticket.thumbnailsUrl);
|
|
expect(
|
|
episode.withVideoUrl('file:///video.mp4').thumbnailsUrl,
|
|
episode.thumbnailsUrl,
|
|
);
|
|
});
|
|
}
|
|
|
|
class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
|
|
_FakeSpriteLoader({this.vtt, this.vttResponses});
|
|
|
|
final String? vtt;
|
|
final List<Object>? vttResponses;
|
|
int vttCalls = 0;
|
|
final List<String> spriteCalls = [];
|
|
|
|
@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);
|
|
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;
|
|
}
|