refactor(features): consolidate shared feature primitives
This commit is contained in:
@@ -4,9 +4,10 @@ import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_item_merge.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
void main() {
|
||||
MediaItem item({String? serverId, String? serverName, String? libraryId, String? libraryTitle}) => MediaItem(
|
||||
MediaItem item({String? serverId, String? serverName, String? libraryId, String? libraryTitle}) => testMediaItem(
|
||||
id: 'item',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.movie,
|
||||
@@ -17,14 +18,14 @@ void main() {
|
||||
);
|
||||
|
||||
test('uses the authoritative fallback when both items omit server identity', () {
|
||||
final merged = mergeFetchedMediaItem(fetched: item(), fallbackServerId: ServerId('fallback'));
|
||||
final merged = mergeFetchedtestMediaItem(fetched: item(), fallbackServerId: ServerId('fallback'));
|
||||
|
||||
expect(merged.serverId, 'fallback');
|
||||
expect(merged.globalKey, 'fallback:item');
|
||||
});
|
||||
|
||||
test('preserves existing identity while preferring fetched library context', () {
|
||||
final merged = mergeFetchedMediaItem(
|
||||
final merged = mergeFetchedtestMediaItem(
|
||||
fetched: item(serverId: 'fetched', serverName: 'Fetched', libraryId: 'new-lib', libraryTitle: 'New'),
|
||||
existing: item(serverId: 'existing', serverName: 'Existing', libraryId: 'old-lib', libraryTitle: 'Old'),
|
||||
fallbackServerId: ServerId('fallback'),
|
||||
@@ -37,7 +38,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('fills missing fetched library context from the existing item', () {
|
||||
final merged = mergeFetchedMediaItem(
|
||||
final merged = mergeFetchedtestMediaItem(
|
||||
fetched: item(),
|
||||
existing: item(libraryId: 'old-lib', libraryTitle: 'Old'),
|
||||
fallbackServerId: ServerId('fallback'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_part.dart';
|
||||
import 'package:plezy/media/media_role.dart';
|
||||
import 'package:plezy/media/media_version.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
/// Backend-agnostic [MediaItem] tests. Existing coverage is split between
|
||||
/// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the
|
||||
@@ -22,7 +23,7 @@ MediaItem _movie({
|
||||
String? artPath,
|
||||
String? backgroundSquarePath,
|
||||
MediaBackend backend = MediaBackend.plex,
|
||||
}) => MediaItem(
|
||||
}) => testMediaItem(
|
||||
id: id,
|
||||
backend: backend,
|
||||
kind: MediaKind.movie,
|
||||
@@ -50,7 +51,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('show with all leaves watched is watched', () {
|
||||
final show = MediaItem(
|
||||
final show = testMediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
@@ -62,7 +63,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('show with viewedLeafCount > leafCount is still watched (defensive)', () {
|
||||
final show = MediaItem(
|
||||
final show = testMediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
@@ -74,7 +75,13 @@ void main() {
|
||||
});
|
||||
|
||||
test('show with no leaf info falls back to viewCount', () {
|
||||
final show = MediaItem(id: 's', backend: MediaBackend.plex, kind: MediaKind.show, viewCount: 1, serverId: 's1');
|
||||
final show = testMediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
viewCount: 1,
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(show.isWatched, isTrue);
|
||||
});
|
||||
});
|
||||
@@ -102,7 +109,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('episodes prefer show art before episode art for wide hero containers', () {
|
||||
final episode = MediaItem(
|
||||
final episode = testMediaItem(
|
||||
id: 'e1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
@@ -122,7 +129,7 @@ void main() {
|
||||
|
||||
group('MediaItem.isPartiallyWatched', () {
|
||||
test('show with some leaves watched is partially watched', () {
|
||||
final show = MediaItem(
|
||||
final show = testMediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
@@ -134,7 +141,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('show with zero leaves watched is NOT partially watched', () {
|
||||
final show = MediaItem(
|
||||
final show = testMediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
@@ -146,7 +153,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('show with all leaves watched is NOT partially watched', () {
|
||||
final show = MediaItem(
|
||||
final show = testMediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
@@ -210,7 +217,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('preserves Plex-only fields when omitted', () {
|
||||
const original = PlexMediaItem(
|
||||
const original = PlextestMediaItem(
|
||||
id: 'p1',
|
||||
kind: MediaKind.movie,
|
||||
title: 'Old',
|
||||
@@ -244,7 +251,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('preserves Jellyfin playlist item id when omitted', () {
|
||||
const original = JellyfinMediaItem(
|
||||
const original = JellyfintestMediaItem(
|
||||
id: 'j1',
|
||||
kind: MediaKind.movie,
|
||||
title: 'Old',
|
||||
@@ -270,7 +277,7 @@ void main() {
|
||||
|
||||
group('MediaItem JSON', () {
|
||||
test('round-trips Plex-only fields', () {
|
||||
const original = PlexMediaItem(
|
||||
const original = PlextestMediaItem(
|
||||
id: 'p1',
|
||||
kind: MediaKind.movie,
|
||||
title: 'Movie',
|
||||
@@ -321,7 +328,12 @@ void main() {
|
||||
});
|
||||
|
||||
test('round-trips Jellyfin playlist item id', () {
|
||||
const original = JellyfinMediaItem(id: 'j1', kind: MediaKind.movie, title: 'Movie', playlistItemId: 'entry-1');
|
||||
const original = JellyfintestMediaItem(
|
||||
id: 'j1',
|
||||
kind: MediaKind.movie,
|
||||
title: 'Movie',
|
||||
playlistItemId: 'entry-1',
|
||||
);
|
||||
|
||||
final json = original.toJson();
|
||||
final decoded = MediaItem.fromJson(json);
|
||||
@@ -343,7 +355,7 @@ void main() {
|
||||
|
||||
group('MediaItem.displayTitle', () {
|
||||
test('episode prefers grandparent (show) title', () {
|
||||
final ep = MediaItem(
|
||||
final ep = testMediaItem(
|
||||
id: 'e1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
@@ -357,7 +369,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('season prefers grandparent over parent (when both present)', () {
|
||||
final season = MediaItem(
|
||||
final season = testMediaItem(
|
||||
id: 'sn1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.season,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/stepped_seek.dart';
|
||||
|
||||
void main() {
|
||||
test('stepped multiplier preserves shared acceleration tiers', () {
|
||||
expect(steppedSeekMultiplier(0), 1.5);
|
||||
expect(steppedSeekMultiplier(5), 1.5);
|
||||
expect(steppedSeekMultiplier(6), 3.0);
|
||||
expect(steppedSeekMultiplier(15), 3.0);
|
||||
expect(steppedSeekMultiplier(16), 6.0);
|
||||
expect(steppedSeekMultiplier(30), 6.0);
|
||||
expect(steppedSeekMultiplier(31), 10.0);
|
||||
});
|
||||
|
||||
test('rapid steps accumulate and debounce into one seek', () {
|
||||
fakeAsync((async) {
|
||||
var position = const Duration(seconds: 20);
|
||||
final seeks = <Duration>[];
|
||||
final accumulator = DebouncedSeekAccumulator(
|
||||
currentPosition: () => position,
|
||||
duration: () => const Duration(minutes: 2),
|
||||
seek: seeks.add,
|
||||
);
|
||||
|
||||
accumulator.seekBy(const Duration(seconds: 10));
|
||||
accumulator.seekBy(const Duration(seconds: 15));
|
||||
accumulator.seekBy(const Duration(seconds: -5));
|
||||
|
||||
expect(accumulator.pendingPosition, const Duration(seconds: 40));
|
||||
async.elapse(const Duration(milliseconds: 799));
|
||||
expect(seeks, isEmpty);
|
||||
async.elapse(const Duration(milliseconds: 1));
|
||||
expect(seeks, [const Duration(seconds: 40)]);
|
||||
|
||||
// A slow player still reports the old position. The next burst must use
|
||||
// the pinned target rather than silently dropping the committed seek.
|
||||
accumulator.seekBy(const Duration(seconds: 10));
|
||||
accumulator.flush();
|
||||
expect(seeks, [const Duration(seconds: 40), const Duration(seconds: 50)]);
|
||||
|
||||
position = const Duration(seconds: 50);
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(accumulator.pendingPosition, isNull);
|
||||
accumulator.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps targets and cancel prevents a pending seek', () {
|
||||
fakeAsync((async) {
|
||||
final seeks = <Duration>[];
|
||||
final accumulator = DebouncedSeekAccumulator(
|
||||
currentPosition: () => const Duration(seconds: 5),
|
||||
duration: () => const Duration(seconds: 30),
|
||||
seek: seeks.add,
|
||||
);
|
||||
|
||||
accumulator.seekBy(const Duration(minutes: 1));
|
||||
expect(accumulator.pendingPosition, const Duration(seconds: 30));
|
||||
accumulator.cancel();
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(seeks, isEmpty);
|
||||
expect(accumulator.pendingPosition, isNull);
|
||||
accumulator.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user