refactor(features): consolidate shared feature primitives

This commit is contained in:
edde746
2026-07-12 17:31:13 +02:00
parent 6506f36843
commit 7ecadffdb1
103 changed files with 1718 additions and 898 deletions
+5 -4
View File
@@ -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'),
+26 -14
View File
@@ -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,
+67
View File
@@ -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();
});
});
}