Files
plezy/test/services/library_query_translator_test.dart
T

371 lines
15 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/library_query_translator.dart';
void main() {
group('PlexLibraryQueryTranslator', () {
const translator = PlexLibraryQueryTranslator();
test('empty query produces empty filter map', () {
expect(translator.toQueryParameters(const LibraryQuery()), isEmpty);
});
test('movie kind maps to type=1', () {
final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.movie));
expect(params['type'], '1');
});
test('show kind maps to type=2', () {
final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.show));
expect(params['type'], '2');
});
test('multiple kinds map to a comma-delimited type filter', () {
final params = translator.toQueryParameters(const LibraryQuery(includeKinds: [MediaKind.movie, MediaKind.show]));
expect(params['type'], '1,2');
});
test('collection kind has no Plex type number (filtered separately)', () {
final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.collection));
expect(params, isNot(contains('type')));
});
test('ascending sort appends :asc suffix', () {
final params = translator.toQueryParameters(
const LibraryQuery(
sort: LibrarySort(field: 'titleSort', direction: LibrarySortDirection.ascending),
),
);
expect(params['sort'], 'titleSort:asc');
});
test('parseSortParam strips explicit ascending suffix', () {
final sort = LibraryQueryTranslator.parseSortParam('titleSort:asc');
expect(sort?.field, 'titleSort');
expect(sort?.direction, LibrarySortDirection.ascending);
expect(translator.toQueryParameters(LibraryQuery(sort: sort))['sort'], 'titleSort:asc');
});
test('descending sort appends :desc suffix (default direction)', () {
final params = translator.toQueryParameters(const LibraryQuery(sort: LibrarySort(field: 'addedAt')));
expect(params['sort'], 'addedAt:desc');
});
test('search puts text in title field', () {
final params = translator.toQueryParameters(const LibraryQuery(search: 'star wars'));
expect(params['title'], 'star wars');
});
test('includeWatched=false sets unwatched=1', () {
final params = translator.toQueryParameters(const LibraryQuery(includeWatched: false));
expect(params['unwatched'], '1');
});
test('favoritesOnly is ignored (Plex has no favorites)', () {
expect(translator.toQueryParameters(const LibraryQuery(favoritesOnly: true)), isEmpty);
});
test('arbitrary filter clauses pass through verbatim', () {
final params = translator.toQueryParameters(
const LibraryQuery(
filters: [
LibraryFilter(field: 'genre', values: ['Action', 'Drama']),
],
),
);
expect(params['genre'], 'Action,Drama');
});
});
group('JellyfinLibraryQueryTranslator', () {
const translator = JellyfinLibraryQueryTranslator(userId: 'user-1', parentId: 'lib-1', fields: 'UserData');
test('always sets userId, ParentId, Recursive, IncludeItemTypes', () {
final params = translator.toQueryParameters(const LibraryQuery());
expect(params['userId'], 'user-1');
expect(params['ParentId'], 'lib-1');
expect(params['Recursive'], 'true');
expect(params['Fields'], 'UserData');
expect(params['IncludeItemTypes'], isNotEmpty);
expect(params['EnableTotalRecordCount'], 'true');
expect(params['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(params['ImageTypeLimit'], '3');
});
test('movie kind maps to IncludeItemTypes=Movie', () {
final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.movie));
expect(params['IncludeItemTypes'], 'Movie');
});
test('show kind maps to IncludeItemTypes=Series', () {
final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.show));
expect(params['IncludeItemTypes'], 'Series');
});
test('multiple kinds map to combined IncludeItemTypes', () {
final params = translator.toQueryParameters(
const LibraryQuery(kind: MediaKind.episode, includeKinds: [MediaKind.movie, MediaKind.show]),
);
expect(params['IncludeItemTypes'], 'Movie,Series');
});
test('collection kind maps to IncludeItemTypes=BoxSet', () {
final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.collection));
expect(params['IncludeItemTypes'], 'BoxSet');
});
test('clip and photo kinds map to Jellyfin item types', () {
expect(
translator.toQueryParameters(const LibraryQuery(kind: MediaKind.clip))['IncludeItemTypes'],
'Video,MusicVideo',
);
expect(translator.toQueryParameters(const LibraryQuery(kind: MediaKind.photo))['IncludeItemTypes'], 'Photo');
});
test('null kind falls back to multi-type include', () {
final params = translator.toQueryParameters(const LibraryQuery());
expect(params['IncludeItemTypes'], 'Movie,Series,Episode,Audio');
});
test('genres joined with pipe separator', () {
final params = translator.toQueryParameters(const LibraryQuery(genres: ['Action', 'Drama']));
expect(params['Genres'], 'Action|Drama');
});
test('years joined with comma separator', () {
final params = translator.toQueryParameters(const LibraryQuery(years: [2020, 2021]));
expect(params['Years'], '2020,2021');
});
test('sort field "title" maps to SortName, "addedAt" to DateCreated', () {
final titleSort = translator.toQueryParameters(
const LibraryQuery(
sort: LibrarySort(field: 'title', direction: LibrarySortDirection.ascending),
),
);
expect(titleSort['SortBy'], 'SortName');
expect(titleSort['SortOrder'], 'Ascending');
final addedSort = translator.toQueryParameters(const LibraryQuery(sort: LibrarySort(field: 'addedAt')));
expect(addedSort['SortBy'], 'DateCreated');
expect(addedSort['SortOrder'], 'Descending');
});
test('Streamyfin broad sort keys map to Jellyfin ItemSortBy values', () {
const cases = {
'criticRating': 'CriticRating',
'viewCount': 'PlayCount',
'productionYear': 'ProductionYear',
'runtime': 'Runtime',
'officialRating': 'OfficialRating',
'startDate': 'StartDate',
'airTime': 'AirTime',
'studio': 'Studio',
'episode.addedAt': 'DateLastContentAdded,SortName',
};
for (final entry in cases.entries) {
final params = translator.toQueryParameters(LibraryQuery(sort: LibrarySort(field: entry.key)));
expect(params['SortBy'], entry.value, reason: entry.key);
}
});
test('show date played sort maps to Jellyfin series-specific sort field', () {
final showSort = translator.toQueryParameters(
const LibraryQuery(
kind: MediaKind.show,
sort: LibrarySort(field: 'lastViewedAt'),
),
);
expect(showSort['SortBy'], 'SeriesDatePlayed');
final movieSort = translator.toQueryParameters(
const LibraryQuery(
kind: MediaKind.movie,
sort: LibrarySort(field: 'lastViewedAt'),
),
);
expect(movieSort['SortBy'], 'DatePlayed');
});
test('episode added sort maps to Jellyfin series content added sort', () {
final descending = translator.toQueryParameters(
const LibraryQuery(
kind: MediaKind.show,
sort: LibrarySort(field: 'episode.addedAt'),
),
);
expect(descending['SortBy'], 'DateLastContentAdded,SortName');
expect(descending['SortOrder'], 'Descending');
final ascending = translator.toQueryParameters(
const LibraryQuery(
kind: MediaKind.show,
sort: LibrarySort(field: 'episode.addedAt', direction: LibrarySortDirection.ascending),
),
);
expect(ascending['SortBy'], 'DateLastContentAdded,SortName');
expect(ascending['SortOrder'], 'Ascending');
});
test('nameStartsWith="#" maps to NameLessThan=A', () {
final params = translator.toQueryParameters(const LibraryQuery(nameStartsWith: '#'));
expect(params['NameLessThan'], 'A');
expect(params, isNot(contains('NameStartsWith')));
});
test('nameStartsWith=letter maps to NameStartsWith', () {
final params = translator.toQueryParameters(const LibraryQuery(nameStartsWith: 'B'));
expect(params['NameStartsWith'], 'B');
expect(params, isNot(contains('NameLessThan')));
});
test('includeWatched=false sets Filters=IsUnplayed', () {
final params = translator.toQueryParameters(const LibraryQuery(includeWatched: false));
expect(params['Filters'], 'IsUnplayed');
});
test('favoritesOnly sets Filters=IsFavorite', () {
final params = translator.toQueryParameters(const LibraryQuery(favoritesOnly: true));
expect(params['Filters'], 'IsFavorite');
});
test('unwatched + favorites combine into a comma-separated Filters list', () {
final params = translator.toQueryParameters(const LibraryQuery(includeWatched: false, favoritesOnly: true));
expect(params['Filters'], 'IsUnplayed,IsFavorite');
});
test('search puts text in SearchTerm', () {
final params = translator.toQueryParameters(const LibraryQuery(search: 'matrix'));
expect(params['SearchTerm'], 'matrix');
});
test('offset/limit pass through as StartIndex/Limit strings', () {
final params = translator.toQueryParameters(const LibraryQuery(offset: 50, limit: 25));
expect(params['StartIndex'], '50');
expect(params['Limit'], '25');
});
});
group('LibraryQueryTranslator.parseSortParam', () {
test('returns null for null/empty input', () {
expect(LibraryQueryTranslator.parseSortParam(null), isNull);
expect(LibraryQueryTranslator.parseSortParam(''), isNull);
});
test('parses bare field as ascending', () {
final sort = LibraryQueryTranslator.parseSortParam('addedAt');
expect(sort, isNotNull);
expect(sort!.field, 'addedAt');
expect(sort.direction, LibrarySortDirection.ascending);
});
test('parses field:desc as descending', () {
final sort = LibraryQueryTranslator.parseSortParam('rating:desc');
expect(sort, isNotNull);
expect(sort!.field, 'rating');
expect(sort.direction, LibrarySortDirection.descending);
});
test('handles dotted Plex sort keys without losing the field', () {
final sort = LibraryQueryTranslator.parseSortParam('episode.originallyAvailableAt:desc');
expect(sort!.field, 'episode.originallyAvailableAt');
expect(sort.direction, LibrarySortDirection.descending);
});
test('returns null when only the suffix is present', () {
expect(LibraryQueryTranslator.parseSortParam(':desc'), isNull);
});
});
// The library browse tab still keeps `_selectedFilters` as a Plex-shaped
// map (the FiltersBottomSheet emits that shape) but routes it through
// `libraryQueryFromPlexMap` at the `fetchLibraryPagedContent` boundary.
// The Plex client then translates the resulting `LibraryQuery` back to a
// map via `PlexLibraryQueryTranslator`. Round-tripping must be loss-free
// (modulo the `includeCollections=1` always-on case the client adds back
// explicitly) so user-saved filters from prior versions don't silently
// drop on first reload.
group('libraryQueryFromPlexMap round-trip with PlexLibraryQueryTranslator', () {
const translator = PlexLibraryQueryTranslator();
Map<String, String> roundTrip(Map<String, String> input, {MediaKind? libraryKind}) {
final query = libraryQueryFromPlexMap(map: input, libraryKind: libraryKind);
return translator.toQueryParameters(query);
}
test('genre + sort round-trips into the same map', () {
final input = {'genre': 'Comedy', 'sort': 'addedAt:desc'};
expect(roundTrip(input), {'genre': 'Comedy', 'sort': 'addedAt:desc'});
});
test('multi-value year filter round-trips', () {
final input = {'year': '2010,2011,2012'};
expect(roundTrip(input)['year'], '2010,2011,2012');
});
test('contentRating + tag + alphaPrefix round-trip together', () {
final input = {'contentRating': 'PG-13', 'tag': 'Christmas', 'alphaPrefix': 'A'};
expect(roundTrip(input), {'contentRating': 'PG-13', 'tag': 'Christmas', 'alphaPrefix': 'A'});
});
test('unwatched=1 round-trips (LibraryQuery.includeWatched=false → unwatched=1)', () {
final input = {'unwatched': '1'};
expect(roundTrip(input), {'unwatched': '1'});
});
test('unwatched absent round-trips to absent (default includeWatched=true)', () {
expect(roundTrip(const {}), isEmpty);
});
test('unknown Plex filter keys (director) survive as generic LibraryFilter entries', () {
final input = {'director': '12345'};
expect(roundTrip(input)['director'], '12345');
});
test('favorite=1 maps to favoritesOnly, not a generic filter entry', () {
// Jellyfin-only key; the Plex translator deliberately drops it, so it
// must not leak into the verbatim-pass-through filters bucket either.
final query = libraryQueryFromPlexMap(map: {'favorite': '1'});
expect(query.favoritesOnly, isTrue);
expect(query.filters, isEmpty);
});
test('libraryKind argument overrides any type entry in the map', () {
// The browse tab always passes the library's actual kind; map's `type`
// is dropped if the explicit arg is present.
final query = libraryQueryFromPlexMap(map: {'type': '1'}, libraryKind: MediaKind.show);
expect(query.kind, MediaKind.show);
});
test('multi-value type stays in the generic filters bucket (Plex passes it verbatim)', () {
// Plex shared libraries use `type=1,4` to mean movies+episodes — no
// single MediaKind covers that, so it has to round-trip via filters.
final input = {'type': '1,4'};
expect(roundTrip(input)['type'], '1,4');
});
test('numeric type maps to MediaKind when libraryKind is absent', () {
final query = libraryQueryFromPlexMap(map: {'type': '1'});
expect(query.kind, MediaKind.movie);
});
test('full realistic browse-tab map round-trips byte-for-byte', () {
final input = {
'genre': 'Comedy',
'year': '2024',
'contentRating': 'PG-13',
'tag': 'Christmas',
'unwatched': '1',
'sort': 'rating:desc',
'alphaPrefix': 'B',
};
// includeCollections is added by PlexClient.fetchLibraryPagedContent
// *after* the translator, so the round-trip-only output skips it. The
// production path still emits it.
expect(roundTrip(input), input);
});
});
}