feat: jellyfin
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
|
||||
/// Backend-agnostic [MediaItem] tests. Existing coverage is split between
|
||||
/// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the
|
||||
/// JSON mappers but never the neutral model itself. If a mapper is removed
|
||||
/// or refactored these tests still pin the model contract: equality,
|
||||
/// copyWith, watch-state derived getters.
|
||||
MediaItem _movie({
|
||||
String id = 'm1',
|
||||
String? title = 'Movie',
|
||||
int? viewCount,
|
||||
int? leafCount,
|
||||
int? viewedLeafCount,
|
||||
int? durationMs,
|
||||
int? viewOffsetMs,
|
||||
MediaBackend backend = MediaBackend.plex,
|
||||
}) => MediaItem(
|
||||
id: id,
|
||||
backend: backend,
|
||||
kind: MediaKind.movie,
|
||||
title: title,
|
||||
viewCount: viewCount,
|
||||
leafCount: leafCount,
|
||||
viewedLeafCount: viewedLeafCount,
|
||||
durationMs: durationMs,
|
||||
viewOffsetMs: viewOffsetMs,
|
||||
serverId: 's1',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('MediaItem.isWatched', () {
|
||||
test('movie with viewCount > 0 is watched', () {
|
||||
expect(_movie(viewCount: 1).isWatched, isTrue);
|
||||
expect(_movie(viewCount: 5).isWatched, isTrue);
|
||||
});
|
||||
|
||||
test('movie with viewCount 0 or null is unwatched', () {
|
||||
expect(_movie(viewCount: 0).isWatched, isFalse);
|
||||
expect(_movie(viewCount: null).isWatched, isFalse);
|
||||
});
|
||||
|
||||
test('show with all leaves watched is watched', () {
|
||||
final show = MediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
leafCount: 10,
|
||||
viewedLeafCount: 10,
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(show.isWatched, isTrue);
|
||||
});
|
||||
|
||||
test('show with viewedLeafCount > leafCount is still watched (defensive)', () {
|
||||
final show = MediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
leafCount: 10,
|
||||
viewedLeafCount: 11,
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(show.isWatched, isTrue);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(show.isWatched, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaItem.isPartiallyWatched', () {
|
||||
test('show with some leaves watched is partially watched', () {
|
||||
final show = MediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
leafCount: 10,
|
||||
viewedLeafCount: 3,
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(show.isPartiallyWatched, isTrue);
|
||||
});
|
||||
|
||||
test('show with zero leaves watched is NOT partially watched', () {
|
||||
final show = MediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
leafCount: 10,
|
||||
viewedLeafCount: 0,
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(show.isPartiallyWatched, isFalse);
|
||||
});
|
||||
|
||||
test('show with all leaves watched is NOT partially watched', () {
|
||||
final show = MediaItem(
|
||||
id: 's',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
leafCount: 10,
|
||||
viewedLeafCount: 10,
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(show.isPartiallyWatched, isFalse);
|
||||
});
|
||||
|
||||
test('movie without leaf info is NOT partially watched (concept doesn\'t apply)', () {
|
||||
expect(_movie(viewCount: 0).isPartiallyWatched, isFalse);
|
||||
expect(_movie(viewCount: 1).isPartiallyWatched, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaItem.hasActiveProgress', () {
|
||||
test('viewOffset between 0 and duration counts as active progress', () {
|
||||
expect(_movie(durationMs: 10000, viewOffsetMs: 5000).hasActiveProgress, isTrue);
|
||||
});
|
||||
|
||||
test('viewOffset 0 is NOT active progress (haven\'t started yet)', () {
|
||||
expect(_movie(durationMs: 10000, viewOffsetMs: 0).hasActiveProgress, isFalse);
|
||||
});
|
||||
|
||||
test('viewOffset >= duration is NOT active progress (already finished)', () {
|
||||
expect(_movie(durationMs: 10000, viewOffsetMs: 10000).hasActiveProgress, isFalse);
|
||||
expect(_movie(durationMs: 10000, viewOffsetMs: 99999).hasActiveProgress, isFalse);
|
||||
});
|
||||
|
||||
test('null durationMs or viewOffsetMs disables the check', () {
|
||||
expect(_movie(durationMs: null, viewOffsetMs: 5000).hasActiveProgress, isFalse);
|
||||
expect(_movie(durationMs: 10000, viewOffsetMs: null).hasActiveProgress, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaItem.copyWith', () {
|
||||
test('round-trips an unchanged copy', () {
|
||||
final original = _movie(viewCount: 1, durationMs: 1000);
|
||||
final copy = original.copyWith();
|
||||
expect(copy.id, original.id);
|
||||
expect(copy.viewCount, original.viewCount);
|
||||
expect(copy.durationMs, original.durationMs);
|
||||
expect(copy.kind, original.kind);
|
||||
});
|
||||
|
||||
test('overrides only the named fields', () {
|
||||
final original = _movie(title: 'Old', viewCount: 0);
|
||||
final copy = original.copyWith(title: 'New', viewCount: 3);
|
||||
expect(copy.title, 'New');
|
||||
expect(copy.viewCount, 3);
|
||||
expect(copy.id, 'm1', reason: 'untouched fields preserved');
|
||||
});
|
||||
|
||||
test('preserves backend across copyWith for both backends', () {
|
||||
for (final backend in MediaBackend.values) {
|
||||
final original = _movie(backend: backend);
|
||||
expect(original.backend, backend);
|
||||
expect(original.copyWith(title: 'New').backend, backend, reason: 'copyWith must preserve backend');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaItem.displayTitle', () {
|
||||
test('episode prefers grandparent (show) title', () {
|
||||
final ep = MediaItem(
|
||||
id: 'e1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Pilot',
|
||||
grandparentTitle: 'Breaking Bad',
|
||||
parentTitle: 'Season 1',
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(ep.displayTitle, 'Breaking Bad');
|
||||
expect(ep.displaySubtitle, 'Pilot');
|
||||
});
|
||||
|
||||
test('season prefers grandparent over parent (when both present)', () {
|
||||
final season = MediaItem(
|
||||
id: 'sn1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.season,
|
||||
title: 'Season 1',
|
||||
grandparentTitle: 'Breaking Bad',
|
||||
parentTitle: null,
|
||||
serverId: 's1',
|
||||
);
|
||||
expect(season.displayTitle, 'Breaking Bad');
|
||||
});
|
||||
|
||||
test('movie returns its own title with no subtitle', () {
|
||||
final movie = _movie(title: 'Inception');
|
||||
expect(movie.displayTitle, 'Inception');
|
||||
expect(movie.displaySubtitle, isNull);
|
||||
});
|
||||
|
||||
test('null title degrades to empty string (no NPE)', () {
|
||||
final movie = _movie(title: null);
|
||||
expect(movie.displayTitle, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_playlist.dart';
|
||||
|
||||
/// Backend-agnostic [MediaPlaylist] tests. Mappers (`plex_mappers_test` /
|
||||
/// `jellyfin_mappers_test`) cover JSON → model translation; this file pins
|
||||
/// the neutral model's surface so a future mapper swap can't silently
|
||||
/// regress its derived getters.
|
||||
///
|
||||
/// Note: [MediaPlaylist] does **not** override `==` / `hashCode`, so this
|
||||
/// file deliberately avoids equality tests that would exercise default
|
||||
/// identity behavior.
|
||||
MediaPlaylist _playlist({
|
||||
String id = 'pl1',
|
||||
MediaBackend backend = MediaBackend.plex,
|
||||
String title = 'My Playlist',
|
||||
String playlistType = 'video',
|
||||
bool smart = false,
|
||||
String? compositeImagePath,
|
||||
String? thumbPath,
|
||||
String? serverId = 's1',
|
||||
}) => MediaPlaylist(
|
||||
id: id,
|
||||
backend: backend,
|
||||
title: title,
|
||||
playlistType: playlistType,
|
||||
smart: smart,
|
||||
compositeImagePath: compositeImagePath,
|
||||
thumbPath: thumbPath,
|
||||
serverId: serverId,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('MediaPlaylist.copyWith', () {
|
||||
test('returns an equivalent copy when no overrides are passed', () {
|
||||
final original = _playlist(
|
||||
id: 'pl-original',
|
||||
title: 'Original',
|
||||
compositeImagePath: '/library/metadata/123/composite/1700000000',
|
||||
thumbPath: '/library/metadata/123/thumb',
|
||||
serverId: 's-original',
|
||||
);
|
||||
final copy = original.copyWith();
|
||||
expect(copy.id, original.id);
|
||||
expect(copy.backend, original.backend);
|
||||
expect(copy.title, original.title);
|
||||
expect(copy.playlistType, original.playlistType);
|
||||
expect(copy.compositeImagePath, original.compositeImagePath);
|
||||
expect(copy.thumbPath, original.thumbPath);
|
||||
expect(copy.serverId, original.serverId);
|
||||
});
|
||||
|
||||
test('overrides apply to the copy without mutating the source', () {
|
||||
final original = _playlist(title: 'Original', smart: false);
|
||||
final renamed = original.copyWith(title: 'Renamed', smart: true);
|
||||
expect(renamed.title, 'Renamed');
|
||||
expect(renamed.smart, isTrue);
|
||||
// Source untouched — copyWith must be non-mutating.
|
||||
expect(original.title, 'Original');
|
||||
expect(original.smart, isFalse);
|
||||
});
|
||||
|
||||
test('every nullable field can be overridden', () {
|
||||
final original = _playlist();
|
||||
final fully = original.copyWith(
|
||||
id: 'new-id',
|
||||
backend: MediaBackend.jellyfin,
|
||||
title: 'New Title',
|
||||
summary: 'A new summary',
|
||||
guid: 'plex://playlist/abc',
|
||||
smart: true,
|
||||
playlistType: 'audio',
|
||||
durationMs: 1234567,
|
||||
leafCount: 42,
|
||||
viewCount: 7,
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700001000,
|
||||
lastViewedAt: 1700002000,
|
||||
compositeImagePath: '/composite/x',
|
||||
thumbPath: '/thumb/x',
|
||||
serverId: 'new-server',
|
||||
serverName: 'New Server',
|
||||
);
|
||||
expect(fully.id, 'new-id');
|
||||
expect(fully.backend, MediaBackend.jellyfin);
|
||||
expect(fully.title, 'New Title');
|
||||
expect(fully.summary, 'A new summary');
|
||||
expect(fully.guid, 'plex://playlist/abc');
|
||||
expect(fully.smart, isTrue);
|
||||
expect(fully.playlistType, 'audio');
|
||||
expect(fully.durationMs, 1234567);
|
||||
expect(fully.leafCount, 42);
|
||||
expect(fully.viewCount, 7);
|
||||
expect(fully.addedAt, 1700000000);
|
||||
expect(fully.updatedAt, 1700001000);
|
||||
expect(fully.lastViewedAt, 1700002000);
|
||||
expect(fully.compositeImagePath, '/composite/x');
|
||||
expect(fully.thumbPath, '/thumb/x');
|
||||
expect(fully.serverId, 'new-server');
|
||||
expect(fully.serverName, 'New Server');
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaPlaylist.displayImagePath', () {
|
||||
test('prefers compositeImagePath over thumbPath', () {
|
||||
final pl = _playlist(compositeImagePath: '/composite/grid', thumbPath: '/thumb/single');
|
||||
expect(pl.displayImagePath, '/composite/grid');
|
||||
});
|
||||
|
||||
test('falls back to thumbPath when composite is null', () {
|
||||
final pl = _playlist(compositeImagePath: null, thumbPath: '/thumb/single');
|
||||
expect(pl.displayImagePath, '/thumb/single');
|
||||
});
|
||||
|
||||
test('is null when both are null', () {
|
||||
final pl = _playlist(compositeImagePath: null, thumbPath: null);
|
||||
expect(pl.displayImagePath, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaPlaylist.displayTitle', () {
|
||||
test('is an alias of title', () {
|
||||
final pl = _playlist(title: 'Anything');
|
||||
expect(pl.displayTitle, 'Anything');
|
||||
expect(pl.displayTitle, pl.title);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaPlaylist.isEditable', () {
|
||||
test('smart playlists are read-only (Plex semantics)', () {
|
||||
expect(_playlist(smart: true).isEditable, isFalse);
|
||||
});
|
||||
|
||||
test('manual playlists are editable', () {
|
||||
expect(_playlist(smart: false).isEditable, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaPlaylist.globalKey', () {
|
||||
test('uses "<serverId>:<id>" when serverId is set', () {
|
||||
final pl = _playlist(id: 'pl-42', serverId: 'srv-9');
|
||||
expect(pl.globalKey, 'srv-9:pl-42');
|
||||
});
|
||||
|
||||
test('falls back to bare id when serverId is null', () {
|
||||
final pl = _playlist(id: 'pl-42', serverId: null);
|
||||
expect(pl.globalKey, 'pl-42');
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaPlaylist construction', () {
|
||||
test('tolerates all-optional fields being null', () {
|
||||
final minimal = MediaPlaylist(id: 'pl', backend: MediaBackend.plex, title: 'Min', playlistType: 'video');
|
||||
expect(minimal.summary, isNull);
|
||||
expect(minimal.guid, isNull);
|
||||
expect(minimal.smart, isFalse);
|
||||
expect(minimal.durationMs, isNull);
|
||||
expect(minimal.leafCount, isNull);
|
||||
expect(minimal.viewCount, isNull);
|
||||
expect(minimal.addedAt, isNull);
|
||||
expect(minimal.updatedAt, isNull);
|
||||
expect(minimal.lastViewedAt, isNull);
|
||||
expect(minimal.compositeImagePath, isNull);
|
||||
expect(minimal.thumbPath, isNull);
|
||||
expect(minimal.serverId, isNull);
|
||||
expect(minimal.serverName, isNull);
|
||||
expect(minimal.displayImagePath, isNull);
|
||||
expect(minimal.displayTitle, 'Min');
|
||||
expect(minimal.isEditable, isTrue);
|
||||
// Without a serverId, globalKey reduces to the bare id.
|
||||
expect(minimal.globalKey, 'pl');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_sort.dart';
|
||||
|
||||
void main() {
|
||||
group('MediaSort.getSortKey', () {
|
||||
test('returns plain key for ascending', () {
|
||||
final s = MediaSort(key: 'titleSort', title: 'Title');
|
||||
expect(s.getSortKey(), 'titleSort');
|
||||
expect(s.getSortKey(descending: false), 'titleSort');
|
||||
});
|
||||
|
||||
test('appends :desc when no descKey is provided', () {
|
||||
final s = MediaSort(key: 'addedAt', title: 'Recently Added');
|
||||
expect(s.getSortKey(descending: true), 'addedAt:desc');
|
||||
});
|
||||
|
||||
test('uses explicit descKey when provided', () {
|
||||
final s = MediaSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title');
|
||||
expect(s.getSortKey(descending: true), 'titleSort:desc');
|
||||
|
||||
final custom = MediaSort(key: 'rating', descKey: 'rating.desc.custom', title: 'Rating');
|
||||
expect(custom.getSortKey(descending: true), 'rating.desc.custom');
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaSort.isDefaultDescending', () {
|
||||
test('true for "desc" (case-insensitive)', () {
|
||||
expect(MediaSort(key: 'k', title: 't', defaultDirection: 'desc').isDefaultDescending, isTrue);
|
||||
expect(MediaSort(key: 'k', title: 't', defaultDirection: 'DESC').isDefaultDescending, isTrue);
|
||||
expect(MediaSort(key: 'k', title: 't', defaultDirection: 'Desc').isDefaultDescending, isTrue);
|
||||
});
|
||||
|
||||
test('false for "asc", null, or other values', () {
|
||||
expect(MediaSort(key: 'k', title: 't', defaultDirection: 'asc').isDefaultDescending, isFalse);
|
||||
expect(MediaSort(key: 'k', title: 't').isDefaultDescending, isFalse);
|
||||
expect(MediaSort(key: 'k', title: 't', defaultDirection: '').isDefaultDescending, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaSort.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final s = MediaSort.fromJson({
|
||||
'key': 'titleSort',
|
||||
'descKey': 'titleSort:desc',
|
||||
'title': 'Title',
|
||||
'defaultDirection': 'asc',
|
||||
});
|
||||
expect(s.key, 'titleSort');
|
||||
expect(s.descKey, 'titleSort:desc');
|
||||
expect(s.title, 'Title');
|
||||
expect(s.defaultDirection, 'asc');
|
||||
});
|
||||
|
||||
test('tolerates missing optional fields', () {
|
||||
final s = MediaSort.fromJson({'key': 'k', 'title': 't'});
|
||||
expect(s.descKey, isNull);
|
||||
expect(s.defaultDirection, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaSort equality & hashCode', () {
|
||||
test('equality is based on key only (matches current contract)', () {
|
||||
final a = MediaSort(key: 'k', descKey: 'k:desc', title: 'A', defaultDirection: 'asc');
|
||||
final b = MediaSort(key: 'k', descKey: 'other', title: 'B', defaultDirection: 'desc');
|
||||
expect(a, equals(b));
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
|
||||
test('different keys are not equal', () {
|
||||
final a = MediaSort(key: 'k1', title: 'A');
|
||||
final b = MediaSort(key: 'k2', title: 'A');
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('identity short-circuit', () {
|
||||
final a = MediaSort(key: 'k', title: 't');
|
||||
expect(a == a, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user