// 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_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? vttResponses; int vttCalls = 0; final List spriteCalls = []; @override Future 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 loadSprite(Uri uri) async { spriteCalls.add(uri.pathSegments.last); final image = await _createImage(320, 180); return SpriteThumbnailDecodedImage( image: image, originalWidth: 320, originalHeight: 180, ); } } Future _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; }