Files
plezy/test/services/jellyfin_trickplay_service_test.dart
T

280 lines
9.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter/painting.dart' show ImageProvider, MemoryImage;
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/jellyfin_trickplay_service.dart';
import 'package:plezy/services/scrub_preview_source.dart';
JellyfinConnection _conn() => JellyfinConnection(
id: 'srv-1/user-1',
baseUrl: 'https://jf.example.com',
serverName: 'Home',
serverMachineId: 'srv-1',
userId: 'user-1',
userName: 'edde',
accessToken: 'tok-abc',
deviceId: 'dev-xyz',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
TrickplayInfo _info({
int width = 320,
int height = 180,
int tileWidth = 10,
int tileHeight = 10,
int thumbnailCount = 250,
int interval = 1000,
int bandwidth = 0,
}) => TrickplayInfo(
width: width,
height: height,
tileWidth: tileWidth,
tileHeight: tileHeight,
thumbnailCount: thumbnailCount,
interval: interval,
bandwidth: bandwidth,
);
/// Stub that returns a constant 1×1 transparent image for any URL — keeps
/// tests off path_provider / the image disk cache.
ImageProvider _fakeSheet(String _) => MemoryImage(Uint8List.fromList(const [0]));
void main() {
group('JellyfinTrickplayService.create — width selection', () {
late JellyfinClient client;
setUp(() async {
client = await JellyfinClient.create(_conn());
});
tearDown(() => client.close());
test('returns null on empty manifest', () {
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-1',
mediaSourceId: null,
manifest: const {},
sheetImageBuilder: _fakeSheet,
);
expect(svc, isNull);
});
test('picks the smallest width >= target tooltip width', () {
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-1',
mediaSourceId: null,
manifest: {160: _info(width: 160), 240: _info(width: 240), 320: _info(width: 320)},
sheetImageBuilder: _fakeSheet,
);
expect(svc, isNotNull);
// Indirectly: at t=0 the tile crop's width matches the chosen tile width.
expect(svc!.tileLocationFor(Duration.zero)?.sourceTileSize.width, 160);
});
test('falls back to largest available when nothing meets the target', () {
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-1',
mediaSourceId: null,
manifest: {80: _info(width: 80), 120: _info(width: 120)},
sheetImageBuilder: _fakeSheet,
);
expect(svc, isNotNull);
expect(svc!.tileLocationFor(Duration.zero)?.sourceTileSize.width, 120);
});
});
group('JellyfinTrickplayService.tileLocationFor — index math', () {
late JellyfinClient client;
// 250 thumbnails, 1s apart, 10×10 tiles per sheet ⇒ 3 sheets.
// sheet 0: indices 0..99
// sheet 1: indices 100..199 (full)
// sheet 2: indices 200..249 (last sheet, padded to a full 10x10 grid)
setUp(() async {
client = await JellyfinClient.create(_conn());
});
tearDown(() => client.close());
JellyfinTrickplayService make({String? sourceId, int width = 320}) {
return JellyfinTrickplayService.create(
client: client,
itemId: 'item-1',
mediaSourceId: sourceId,
manifest: {width: _info(width: width)},
sheetImageBuilder: _fakeSheet,
)!;
}
test('t=0 maps to sheet 0, tile (0,0)', () {
final loc = make().tileLocationFor(Duration.zero)!;
expect(loc.sheetIndex, 0);
expect(loc.tileColumn, 0);
expect(loc.tileRow, 0);
});
test('t=99s maps to sheet 0, tile (9,9) — last tile in sheet 0', () {
final loc = make().tileLocationFor(const Duration(seconds: 99))!;
expect(loc.sheetIndex, 0);
expect(loc.tileColumn, 9);
expect(loc.tileRow, 9);
});
test('t=100s rolls into sheet 1, tile (0,0)', () {
final loc = make().tileLocationFor(const Duration(seconds: 100))!;
expect(loc.sheetIndex, 1);
expect(loc.tileColumn, 0);
expect(loc.tileRow, 0);
// Sheet 1 is full ⇒ 10 cols × 10 rows.
expect(loc.sheetColumns, 10);
expect(loc.sheetRows, 10);
});
test('t=249s lands at the last tile of the last (partial) sheet', () {
// index 249 ⇒ sheet 2, tile-in-sheet 49, col=9, row=4
final loc = make().tileLocationFor(const Duration(seconds: 249))!;
expect(loc.sheetIndex, 2);
expect(loc.tileColumn, 9);
expect(loc.tileRow, 4);
// Jellyfin pads the final tile image to the full configured grid, so
// the renderer must keep full sheet geometry even for partial sheets.
expect(loc.sheetColumns, 10);
expect(loc.sheetRows, 10);
});
test('clamps past the end to the last available thumbnail', () {
final loc = make().tileLocationFor(const Duration(seconds: 10000))!;
expect(loc.sheetIndex, 2);
expect(loc.tileColumn, 9);
expect(loc.tileRow, 4);
});
test('isAvailable is true while there are thumbnails', () {
expect(make().isAvailable, isTrue);
});
test('dispose flips isAvailable to false and frames to null', () {
final svc = make();
svc.dispose();
expect(svc.isAvailable, isFalse);
expect(svc.tileLocationFor(Duration.zero), isNull);
expect(svc.getFrame(Duration.zero), isNull);
});
});
group('JellyfinTrickplayService — padded last sheet sizing', () {
late JellyfinClient client;
setUp(() async {
client = await JellyfinClient.create(_conn());
});
tearDown(() => client.close());
test('last sheet with 1 thumbnail still reports the full sheet grid', () {
// 17 thumbs, 4×4 sheet ⇒ sheet 0 full (16), sheet 1 has 1 thumb only.
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-1',
mediaSourceId: null,
manifest: {
320: _info(width: 320, height: 180, tileWidth: 4, tileHeight: 4, thumbnailCount: 17, interval: 1000),
},
sheetImageBuilder: _fakeSheet,
)!;
final loc = svc.tileLocationFor(const Duration(seconds: 16))!;
expect(loc.sheetIndex, 1);
expect(loc.tileColumn, 0);
expect(loc.tileRow, 0);
expect(loc.sheetColumns, 4);
expect(loc.sheetRows, 4);
});
});
group('JellyfinTrickplayService — sheet URL forwarding', () {
test('sheet URL includes selected width, sheet index, and MediaSourceId', () async {
final client = await JellyfinClient.create(_conn());
addTearDown(client.close);
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-99',
mediaSourceId: 'src-2',
manifest: {320: _info(width: 320)},
sheetImageBuilder: _fakeSheet,
)!;
final url = svc.sheetUrlFor(1);
final uri = Uri.parse(url);
expect(uri.path, '/Videos/item-99/Trickplay/320/1.jpg');
expect(uri.queryParameters['MediaSourceId'], 'src-2');
expect(uri.queryParameters['api_key'], 'tok-abc');
});
test('sheet URL omits MediaSourceId when null', () async {
final client = await JellyfinClient.create(_conn());
addTearDown(client.close);
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-99',
mediaSourceId: null,
manifest: {320: _info(width: 320)},
sheetImageBuilder: _fakeSheet,
)!;
final uri = Uri.parse(svc.sheetUrlFor(0));
expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse);
});
});
group('JellyfinTrickplayService.getFrame — SheetScrubFrame integration', () {
test('builds a SheetScrubFrame with the injected ImageProvider', () async {
final client = await JellyfinClient.create(_conn());
addTearDown(client.close);
final probe = MemoryImage(Uint8List.fromList(const [0]));
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-1',
mediaSourceId: null,
manifest: {320: _info(width: 320)},
sheetImageBuilder: (_) => probe,
)!;
final frame = svc.getFrame(Duration.zero);
expect(frame, isA<SheetScrubFrame>());
final sheet = (frame as SheetScrubFrame).sheet;
expect(identical(sheet, probe), isTrue);
expect(frame.tileColumn, 0);
expect(frame.tileRow, 0);
expect(frame.sourceTileSize, equals(const Size(320, 180)));
});
test('uses manifest thumbnail dimensions for non-16:9 previews', () async {
final client = await JellyfinClient.create(_conn());
addTearDown(client.close);
final svc = JellyfinTrickplayService.create(
client: client,
itemId: 'item-1',
mediaSourceId: null,
manifest: {320: _info(width: 320, height: 240)},
sheetImageBuilder: _fakeSheet,
)!;
final frame = svc.getFrame(Duration.zero);
expect(frame, isA<SheetScrubFrame>());
expect(frame!.aspectRatio, closeTo(4 / 3, 0.0001));
expect((frame as SheetScrubFrame).sourceTileSize, equals(const Size(320, 240)));
});
});
}
// Pin: keep ScrubPreviewSource referenced — JellyfinTrickplayService
// implements it but the tests rarely need to upcast, and the import
// otherwise looks unused to the analyzer.
// ignore: unused_element
ScrubPreviewSource? _unused;