test: unit tests for utils, models, settings mpv parser

This commit is contained in:
edde746
2026-04-21 20:09:58 +02:00
parent fd701e5d1f
commit d4235a286a
15 changed files with 1651 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex_metadata.dart';
import 'package:plezy/services/settings_service.dart' show EpisodePosterMode;
PlexMetadata _make({
String ratingKey = '1',
String? key,
String? type,
String? title,
String? parentTitle,
String? grandparentTitle,
String? thumb,
String? parentThumb,
String? grandparentThumb,
String? art,
String? parentRatingKey,
String? grandparentRatingKey,
int? duration,
int? viewOffset,
int? viewCount,
int? leafCount,
int? viewedLeafCount,
String? serverId,
}) {
return PlexMetadata(
ratingKey: ratingKey,
key: key,
type: type,
title: title,
parentTitle: parentTitle,
grandparentTitle: grandparentTitle,
thumb: thumb,
parentThumb: parentThumb,
grandparentThumb: grandparentThumb,
art: art,
parentRatingKey: parentRatingKey,
grandparentRatingKey: grandparentRatingKey,
duration: duration,
viewOffset: viewOffset,
viewCount: viewCount,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
serverId: serverId,
);
}
void main() {
group('PlexMetadata.mediaType', () {
test('maps known lowercase type strings', () {
for (final pair in const [
('movie', PlexMediaType.movie),
('show', PlexMediaType.show),
('season', PlexMediaType.season),
('episode', PlexMediaType.episode),
('artist', PlexMediaType.artist),
('album', PlexMediaType.album),
('track', PlexMediaType.track),
('collection', PlexMediaType.collection),
('playlist', PlexMediaType.playlist),
('clip', PlexMediaType.clip),
('photo', PlexMediaType.photo),
]) {
expect(_make(type: pair.$1).mediaType, pair.$2, reason: 'type=${pair.$1}');
}
});
test('case-insensitive', () {
expect(_make(type: 'MOVIE').mediaType, PlexMediaType.movie);
expect(_make(type: 'Episode').mediaType, PlexMediaType.episode);
});
test('unknown / null -> PlexMediaType.unknown', () {
expect(_make(type: null).mediaType, PlexMediaType.unknown);
expect(_make(type: '').mediaType, PlexMediaType.unknown);
expect(_make(type: 'weird').mediaType, PlexMediaType.unknown);
});
});
group('PlexMediaType enum extensions', () {
test('isVideo', () {
expect(PlexMediaType.movie.isVideo, isTrue);
expect(PlexMediaType.episode.isVideo, isTrue);
expect(PlexMediaType.clip.isVideo, isTrue);
expect(PlexMediaType.show.isVideo, isFalse);
expect(PlexMediaType.season.isVideo, isFalse);
expect(PlexMediaType.track.isVideo, isFalse);
});
test('isShowRelated', () {
expect(PlexMediaType.show.isShowRelated, isTrue);
expect(PlexMediaType.season.isShowRelated, isTrue);
expect(PlexMediaType.episode.isShowRelated, isTrue);
expect(PlexMediaType.movie.isShowRelated, isFalse);
expect(PlexMediaType.clip.isShowRelated, isFalse);
});
test('isMusic', () {
expect(PlexMediaType.artist.isMusic, isTrue);
expect(PlexMediaType.album.isMusic, isTrue);
expect(PlexMediaType.track.isMusic, isTrue);
expect(PlexMediaType.movie.isMusic, isFalse);
});
test('isPlayable', () {
expect(PlexMediaType.movie.isPlayable, isTrue);
expect(PlexMediaType.episode.isPlayable, isTrue);
expect(PlexMediaType.clip.isPlayable, isTrue);
expect(PlexMediaType.track.isPlayable, isTrue);
expect(PlexMediaType.show.isPlayable, isFalse);
expect(PlexMediaType.artist.isPlayable, isFalse);
});
test('typeNumber for API-addressable types', () {
expect(PlexMediaType.movie.typeNumber, 1);
expect(PlexMediaType.show.typeNumber, 2);
expect(PlexMediaType.season.typeNumber, 3);
expect(PlexMediaType.episode.typeNumber, 4);
expect(PlexMediaType.artist.typeNumber, 8);
expect(PlexMediaType.album.typeNumber, 9);
expect(PlexMediaType.track.typeNumber, 10);
});
test('typeNumber fallback is 0 for collection/playlist/clip/photo/unknown', () {
expect(PlexMediaType.collection.typeNumber, 0);
expect(PlexMediaType.playlist.typeNumber, 0);
expect(PlexMediaType.clip.typeNumber, 0);
expect(PlexMediaType.photo.typeNumber, 0);
expect(PlexMediaType.unknown.typeNumber, 0);
});
});
group('globalKey', () {
test('joins serverId:ratingKey when serverId present', () {
expect(_make(ratingKey: '42', serverId: 'srv').globalKey, 'srv:42');
});
test('falls back to bare ratingKey when serverId is null', () {
expect(_make(ratingKey: '42').globalKey, '42');
});
});
group('parentChain', () {
test('movie (no parents) -> empty list', () {
expect(_make(type: 'movie').parentChain, isEmpty);
});
test('season (show parent only) -> [show]', () {
expect(_make(type: 'season', grandparentRatingKey: 's1').parentChain, ['s1']);
});
test('episode (season + show) -> [season, show]', () {
expect(_make(type: 'episode', parentRatingKey: 'se1', grandparentRatingKey: 'sh1').parentChain, ['se1', 'sh1']);
});
test('omits null entries (only parent, no grandparent)', () {
expect(_make(parentRatingKey: 'p').parentChain, ['p']);
});
});
group('isLibrarySection & librarySectionKey', () {
test('non-library-section key', () {
final m = _make(key: '/library/metadata/12345');
expect(m.isLibrarySection, isFalse);
expect(m.librarySectionKey, isNull);
});
test('library-section key extracts numeric id', () {
final m = _make(key: '/library/sections/7/all');
expect(m.isLibrarySection, isTrue);
expect(m.librarySectionKey, '7');
});
test('library-section without trailing path still extracts id', () {
final m = _make(key: '/library/sections/12');
expect(m.isLibrarySection, isTrue);
expect(m.librarySectionKey, '12');
});
test('null key -> not a library section', () {
final m = _make();
expect(m.isLibrarySection, isFalse);
expect(m.librarySectionKey, isNull);
});
});
group('displayTitle / displaySubtitle', () {
test('episode with grandparentTitle prefers show name', () {
final m = _make(type: 'episode', title: 'Pilot', grandparentTitle: 'My Show');
expect(m.displayTitle, 'My Show');
expect(m.displaySubtitle, 'Pilot');
});
test('episode without grandparentTitle falls back to title', () {
final m = _make(type: 'episode', title: 'Pilot');
expect(m.displayTitle, 'Pilot');
expect(m.displaySubtitle, isNull);
});
test('season with grandparentTitle shows show name as title, season name as subtitle', () {
final m = _make(type: 'season', title: 'Season 1', grandparentTitle: 'My Show');
expect(m.displayTitle, 'My Show');
expect(m.displaySubtitle, 'Season 1');
});
test('season without grandparent falls back to parentTitle', () {
final m = _make(type: 'season', title: 'Season 1', parentTitle: 'My Show');
expect(m.displayTitle, 'My Show');
expect(m.displaySubtitle, 'Season 1');
});
test('movie uses its own title, no subtitle', () {
final m = _make(type: 'movie', title: 'Inception');
expect(m.displayTitle, 'Inception');
expect(m.displaySubtitle, isNull);
});
test('missing title returns empty displayTitle', () {
final m = _make(type: 'movie');
expect(m.displayTitle, '');
});
});
group('posterThumb', () {
test('episode + seriesPoster -> grandparentThumb, fallback thumb', () {
expect(
_make(type: 'episode', thumb: 't', grandparentThumb: 'g').posterThumb(mode: EpisodePosterMode.seriesPoster),
'g',
);
expect(_make(type: 'episode', thumb: 't').posterThumb(mode: EpisodePosterMode.seriesPoster), 't');
});
test('episode + seasonPoster -> parentThumb → grandparentThumb → thumb', () {
expect(
_make(
type: 'episode',
thumb: 't',
parentThumb: 'p',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.seasonPoster),
'p',
);
expect(
_make(type: 'episode', thumb: 't', grandparentThumb: 'g').posterThumb(mode: EpisodePosterMode.seasonPoster),
'g',
);
expect(_make(type: 'episode', thumb: 't').posterThumb(mode: EpisodePosterMode.seasonPoster), 't');
});
test('episode + episodeThumbnail -> thumb', () {
expect(
_make(
type: 'episode',
thumb: 't',
parentThumb: 'p',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail),
't',
);
});
test('season -> grandparentThumb when available', () {
expect(_make(type: 'season', thumb: 't', grandparentThumb: 'g').posterThumb(), 'g');
});
test('season without grandparent -> thumb', () {
expect(_make(type: 'season', thumb: 't').posterThumb(), 't');
});
test('season + mixed hub + episodeThumbnail -> art fallback thumb', () {
expect(
_make(
type: 'season',
thumb: 't',
art: 'a',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
'a',
);
expect(
_make(
type: 'season',
thumb: 't',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
't',
);
});
test('movie/show in mixed hub + episodeThumbnail -> art, fallback thumb', () {
expect(
_make(
type: 'movie',
thumb: 't',
art: 'a',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
'a',
);
expect(
_make(type: 'show', thumb: 't').posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
't',
);
});
test('movie/show outside mixed hub -> thumb regardless of mode', () {
expect(_make(type: 'movie', thumb: 't', art: 'a').posterThumb(mode: EpisodePosterMode.episodeThumbnail), 't');
});
test('other types default to thumb', () {
expect(_make(type: 'artist', thumb: 't').posterThumb(), 't');
expect(_make(type: 'track', thumb: 't').posterThumb(), 't');
});
});
group('usesWideAspectRatio', () {
test('clips always wide', () {
expect(_make(type: 'clip').usesWideAspectRatio(EpisodePosterMode.seriesPoster), isTrue);
expect(_make(type: 'clip').usesWideAspectRatio(EpisodePosterMode.seasonPoster), isTrue);
});
test('episode + episodeThumbnail is wide', () {
expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.episodeThumbnail), isTrue);
});
test('episode + other modes is not wide', () {
expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.seriesPoster), isFalse);
expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.seasonPoster), isFalse);
});
test('movie/show/season in mixed hub + episodeThumbnail is wide', () {
for (final t in const ['movie', 'show', 'season']) {
expect(
_make(type: t).usesWideAspectRatio(EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
isTrue,
reason: 'type=$t',
);
}
});
test('movie/show/season outside mixed hub is not wide', () {
expect(_make(type: 'movie').usesWideAspectRatio(EpisodePosterMode.episodeThumbnail), isFalse);
});
});
group('watch-state predicates', () {
test('hasActiveProgress requires both duration and viewOffset set, with 0 < vo < dur', () {
expect(_make().hasActiveProgress, isFalse);
expect(_make(duration: 100).hasActiveProgress, isFalse);
expect(_make(viewOffset: 10).hasActiveProgress, isFalse);
expect(_make(duration: 100, viewOffset: 0).hasActiveProgress, isFalse);
expect(_make(duration: 100, viewOffset: 100).hasActiveProgress, isFalse);
expect(_make(duration: 100, viewOffset: 50).hasActiveProgress, isTrue);
expect(_make(duration: 100, viewOffset: 1).hasActiveProgress, isTrue);
expect(_make(duration: 100, viewOffset: 99).hasActiveProgress, isTrue);
});
test('isPartiallyWatched requires leaf counts with 0 < viewed < total', () {
expect(_make().isPartiallyWatched, isFalse);
expect(_make(leafCount: 10).isPartiallyWatched, isFalse);
expect(_make(viewedLeafCount: 3).isPartiallyWatched, isFalse);
expect(_make(leafCount: 10, viewedLeafCount: 0).isPartiallyWatched, isFalse);
expect(_make(leafCount: 10, viewedLeafCount: 10).isPartiallyWatched, isFalse);
expect(_make(leafCount: 10, viewedLeafCount: 3).isPartiallyWatched, isTrue);
});
test('isWatched prefers leaf counts when both present', () {
expect(_make(leafCount: 10, viewedLeafCount: 10).isWatched, isTrue);
expect(_make(leafCount: 10, viewedLeafCount: 11).isWatched, isTrue);
expect(_make(leafCount: 10, viewedLeafCount: 9).isWatched, isFalse);
});
test('isWatched uses viewCount when leaf counts absent', () {
expect(_make(viewCount: 1).isWatched, isTrue);
expect(_make(viewCount: 0).isWatched, isFalse);
expect(_make().isWatched, isFalse);
});
});
group('heroArt', () {
test('uses backgroundSquare when container is squarer than ~1.39', () {
final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg', backgroundSquare: 'square.jpg');
expect(m.heroArt(containerAspectRatio: 1.0), 'square.jpg');
expect(m.heroArt(containerAspectRatio: 1.38), 'square.jpg');
});
test('uses art when container is wider', () {
final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg', backgroundSquare: 'square.jpg');
expect(m.heroArt(containerAspectRatio: 1.39), 'wide.jpg');
expect(m.heroArt(containerAspectRatio: 1.78), 'wide.jpg');
});
test('falls back to art when backgroundSquare is null regardless of aspect', () {
final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg');
expect(m.heroArt(containerAspectRatio: 1.0), 'wide.jpg');
});
});
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex_sort.dart';
void main() {
group('PlexSort.getSortKey', () {
test('returns plain key for ascending', () {
final s = PlexSort(key: 'titleSort', title: 'Title');
expect(s.getSortKey(), 'titleSort');
expect(s.getSortKey(descending: false), 'titleSort');
});
test('appends :desc when no descKey is provided', () {
final s = PlexSort(key: 'addedAt', title: 'Recently Added');
expect(s.getSortKey(descending: true), 'addedAt:desc');
});
test('uses explicit descKey when provided', () {
final s = PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title');
expect(s.getSortKey(descending: true), 'titleSort:desc');
final custom = PlexSort(key: 'rating', descKey: 'rating.desc.custom', title: 'Rating');
expect(custom.getSortKey(descending: true), 'rating.desc.custom');
});
});
group('PlexSort.isDefaultDescending', () {
test('true for "desc" (case-insensitive)', () {
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'desc').isDefaultDescending, isTrue);
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'DESC').isDefaultDescending, isTrue);
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'Desc').isDefaultDescending, isTrue);
});
test('false for "asc", null, or other values', () {
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'asc').isDefaultDescending, isFalse);
expect(PlexSort(key: 'k', title: 't').isDefaultDescending, isFalse);
expect(PlexSort(key: 'k', title: 't', defaultDirection: '').isDefaultDescending, isFalse);
});
});
group('PlexSort.fromJson', () {
test('parses all fields', () {
final s = PlexSort.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 = PlexSort.fromJson({'key': 'k', 'title': 't'});
expect(s.descKey, isNull);
expect(s.defaultDirection, isNull);
});
});
group('PlexSort equality & hashCode', () {
test('equality is based on key only (matches current contract)', () {
final a = PlexSort(key: 'k', descKey: 'k:desc', title: 'A', defaultDirection: 'asc');
final b = PlexSort(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 = PlexSort(key: 'k1', title: 'A');
final b = PlexSort(key: 'k2', title: 'A');
expect(a, isNot(equals(b)));
});
test('identity short-circuit', () {
final a = PlexSort(key: 'k', title: 't');
expect(a == a, isTrue);
});
});
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/settings_service.dart';
void main() {
group('SettingsService.parseMpvConfigText', () {
test('parses plain key=value lines', () {
final out = SettingsService.parseMpvConfigText('hwdec=auto\nvolume=100');
expect(out, {'hwdec': 'auto', 'volume': '100'});
});
test('trims whitespace around key and value', () {
final out = SettingsService.parseMpvConfigText(' hwdec = auto ');
expect(out, {'hwdec': 'auto'});
});
test('skips blank lines', () {
final out = SettingsService.parseMpvConfigText('\n\nhwdec=auto\n\n');
expect(out, {'hwdec': 'auto'});
});
test('skips # comment lines (even with leading whitespace)', () {
final out = SettingsService.parseMpvConfigText('# this is a comment\n # indented comment\nhwdec=auto');
expect(out, {'hwdec': 'auto'});
});
test('skips lines without an = sign', () {
final out = SettingsService.parseMpvConfigText('justakey\nfoo=bar');
expect(out, {'foo': 'bar'});
});
test('skips lines starting with = (empty key)', () {
final out = SettingsService.parseMpvConfigText('=value\nfoo=bar');
expect(out, {'foo': 'bar'});
});
test('preserves = signs in value (splits on first only)', () {
final out = SettingsService.parseMpvConfigText('params=a=1,b=2');
expect(out, {'params': 'a=1,b=2'});
});
test('allows empty value', () {
final out = SettingsService.parseMpvConfigText('flag=');
expect(out, {'flag': ''});
});
test('later duplicate key overrides earlier', () {
final out = SettingsService.parseMpvConfigText('k=1\nk=2');
expect(out, {'k': '2'});
});
test('empty input yields empty map', () {
expect(SettingsService.parseMpvConfigText(''), isEmpty);
});
});
}
+85
View File
@@ -0,0 +1,85 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/base_notifier.dart';
class _IntNotifier extends BaseNotifier<int> {}
void main() {
group('BaseNotifier', () {
test('single listener receives events', () async {
final n = _IntNotifier();
final received = <int>[];
final sub = n.stream.listen(received.add);
n.notify(1);
n.notify(2);
n.notify(3);
await Future<void>.delayed(Duration.zero);
expect(received, [1, 2, 3]);
await sub.cancel();
n.dispose();
});
test('broadcasts to multiple listeners', () async {
final n = _IntNotifier();
final a = <int>[];
final b = <int>[];
final subA = n.stream.listen(a.add);
final subB = n.stream.listen(b.add);
n.notify(10);
n.notify(20);
await Future<void>.delayed(Duration.zero);
expect(a, [10, 20]);
expect(b, [10, 20]);
await subA.cancel();
await subB.cancel();
n.dispose();
});
test('dispose is idempotent', () {
final n = _IntNotifier();
n.dispose();
expect(n.dispose, returnsNormally);
});
test('notify after dispose throws StateError', () {
final n = _IntNotifier();
n.dispose();
expect(() => n.notify(1), throwsStateError);
});
test('stream access after dispose throws StateError', () {
final n = _IntNotifier();
n.dispose();
expect(() => n.stream, throwsStateError);
});
test('controller is lazily created on first access', () async {
final n = _IntNotifier();
// Notify before stream access — should still work (creates controller).
n.notify(7);
final received = <int>[];
final sub = n.stream.listen(received.add);
n.notify(8);
await Future<void>.delayed(Duration.zero);
// 7 is dropped because no listener was attached yet.
expect(received, [8]);
await sub.cancel();
n.dispose();
});
test('dispose closes the underlying controller (done event fires)', () async {
final n = _IntNotifier();
final doneCompleter = Completer<void>();
final sub = n.stream.listen((_) {}, onDone: doneCompleter.complete);
n.dispose();
await doneCompleter.future.timeout(const Duration(seconds: 1));
await sub.cancel();
});
});
}
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/codec_utils.dart';
void main() {
group('CodecUtils.getSubtitleExtension', () {
test('returns srt for null', () {
expect(CodecUtils.getSubtitleExtension(null), 'srt');
});
test('maps subrip/srt -> srt', () {
expect(CodecUtils.getSubtitleExtension('subrip'), 'srt');
expect(CodecUtils.getSubtitleExtension('SRT'), 'srt');
});
test('maps ass/ssa -> ass', () {
expect(CodecUtils.getSubtitleExtension('ass'), 'ass');
expect(CodecUtils.getSubtitleExtension('SSA'), 'ass');
});
test('maps webvtt/vtt -> vtt', () {
expect(CodecUtils.getSubtitleExtension('webvtt'), 'vtt');
expect(CodecUtils.getSubtitleExtension('VTT'), 'vtt');
});
test('maps mov_text -> srt', () {
expect(CodecUtils.getSubtitleExtension('mov_text'), 'srt');
});
test('maps pgs/hdmv_pgs_subtitle -> sup', () {
expect(CodecUtils.getSubtitleExtension('pgs'), 'sup');
expect(CodecUtils.getSubtitleExtension('HDMV_PGS_SUBTITLE'), 'sup');
});
test('maps dvd_subtitle/dvdsub -> sub', () {
expect(CodecUtils.getSubtitleExtension('dvd_subtitle'), 'sub');
expect(CodecUtils.getSubtitleExtension('dvdsub'), 'sub');
});
test('defaults to srt for unknown codec', () {
expect(CodecUtils.getSubtitleExtension('weirdcodec'), 'srt');
expect(CodecUtils.getSubtitleExtension(''), 'srt');
});
});
group('CodecUtils.formatSubtitleCodec', () {
test('maps known codecs to friendly labels', () {
expect(CodecUtils.formatSubtitleCodec('subrip'), 'SRT');
expect(CodecUtils.formatSubtitleCodec('SUBRIP'), 'SRT');
expect(CodecUtils.formatSubtitleCodec('dvd_subtitle'), 'DVD');
expect(CodecUtils.formatSubtitleCodec('webvtt'), 'VTT');
expect(CodecUtils.formatSubtitleCodec('hdmv_pgs_subtitle'), 'PGS');
expect(CodecUtils.formatSubtitleCodec('mov_text'), 'MOV');
});
test('uppercases unknown codecs', () {
expect(CodecUtils.formatSubtitleCodec('foo'), 'FOO');
expect(CodecUtils.formatSubtitleCodec('ass'), 'ASS');
});
});
group('CodecUtils.formatVideoCodec', () {
test('h264 aliases -> H.264', () {
expect(CodecUtils.formatVideoCodec('h264'), 'H.264');
expect(CodecUtils.formatVideoCodec('avc1'), 'H.264');
expect(CodecUtils.formatVideoCodec('avc'), 'H.264');
expect(CodecUtils.formatVideoCodec('H264'), 'H.264');
});
test('hevc aliases -> HEVC', () {
expect(CodecUtils.formatVideoCodec('hevc'), 'HEVC');
expect(CodecUtils.formatVideoCodec('h265'), 'HEVC');
expect(CodecUtils.formatVideoCodec('hev1'), 'HEVC');
});
test('av1/vp8/vp9', () {
expect(CodecUtils.formatVideoCodec('av1'), 'AV1');
expect(CodecUtils.formatVideoCodec('vp8'), 'VP8');
expect(CodecUtils.formatVideoCodec('vp9'), 'VP9');
});
test('mpeg aliases', () {
expect(CodecUtils.formatVideoCodec('mpeg2video'), 'MPEG-2');
expect(CodecUtils.formatVideoCodec('mpeg2'), 'MPEG-2');
expect(CodecUtils.formatVideoCodec('mpeg4'), 'MPEG-4');
});
test('vc1', () {
expect(CodecUtils.formatVideoCodec('vc1'), 'VC-1');
});
test('unknown codec uppercases original input', () {
expect(CodecUtils.formatVideoCodec('foo'), 'FOO');
expect(CodecUtils.formatVideoCodec('Prores'), 'PRORES');
});
});
group('CodecUtils.formatAudioCodec', () {
test('common codecs', () {
expect(CodecUtils.formatAudioCodec('aac'), 'AAC');
expect(CodecUtils.formatAudioCodec('AAC'), 'AAC');
expect(CodecUtils.formatAudioCodec('ac3'), 'AC3');
expect(CodecUtils.formatAudioCodec('truehd'), 'TrueHD');
expect(CodecUtils.formatAudioCodec('flac'), 'FLAC');
expect(CodecUtils.formatAudioCodec('opus'), 'Opus');
expect(CodecUtils.formatAudioCodec('vorbis'), 'Vorbis');
});
test('eac3/ec3 -> E-AC3', () {
expect(CodecUtils.formatAudioCodec('eac3'), 'E-AC3');
expect(CodecUtils.formatAudioCodec('ec3'), 'E-AC3');
});
test('dts family', () {
expect(CodecUtils.formatAudioCodec('dts'), 'DTS');
expect(CodecUtils.formatAudioCodec('dca'), 'DTS');
expect(CodecUtils.formatAudioCodec('dtshd'), 'DTS-HD');
expect(CodecUtils.formatAudioCodec('dts-hd'), 'DTS-HD');
});
test('mp3 aliases', () {
expect(CodecUtils.formatAudioCodec('mp3'), 'MP3');
expect(CodecUtils.formatAudioCodec('mp3float'), 'MP3');
});
test('pcm aliases', () {
expect(CodecUtils.formatAudioCodec('pcm'), 'PCM');
expect(CodecUtils.formatAudioCodec('pcm_s16le'), 'PCM');
expect(CodecUtils.formatAudioCodec('pcm_s24le'), 'PCM');
});
test('unknown codec uppercases original input', () {
expect(CodecUtils.formatAudioCodec('alac'), 'ALAC');
expect(CodecUtils.formatAudioCodec('weird'), 'WEIRD');
});
});
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex_metadata.dart';
import 'package:plezy/utils/content_utils.dart';
PlexMetadata _episode({int? viewOffset, int? duration, int? viewCount, int? leafCount, int? viewedLeafCount}) {
return PlexMetadata(
ratingKey: '1',
type: 'episode',
viewOffset: viewOffset,
duration: duration,
viewCount: viewCount,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
);
}
PlexMetadata _movie({int? viewCount}) {
return PlexMetadata(ratingKey: '1', type: 'movie', viewCount: viewCount);
}
void main() {
group('formatContentRating', () {
test('strips 2-letter country prefixes', () {
expect(formatContentRating('us/PG-13'), 'PG-13');
expect(formatContentRating('gb/15'), '15');
});
test('strips 3-letter country prefixes', () {
expect(formatContentRating('deu/16'), '16');
});
test('case-insensitive matching', () {
expect(formatContentRating('US/PG-13'), 'PG-13');
expect(formatContentRating('Us/PG'), 'PG');
});
test('returns original when no prefix present', () {
expect(formatContentRating('PG-13'), 'PG-13');
expect(formatContentRating('TV-MA'), 'TV-MA');
});
test('returns empty string for null or empty', () {
expect(formatContentRating(null), '');
expect(formatContentRating(''), '');
});
test('does not strip single-letter or digit prefixes', () {
expect(formatContentRating('1/foo'), '1/foo');
expect(formatContentRating('a/foo'), 'a/foo');
});
});
group('PlexMetadataType.shouldHideSpoiler', () {
test('false for non-episodes', () {
expect(_movie().shouldHideSpoiler, isFalse);
final show = PlexMetadata(ratingKey: '1', type: 'show');
expect(show.shouldHideSpoiler, isFalse);
});
test('false when episode has been watched (viewCount > 0)', () {
expect(_episode(viewCount: 1).shouldHideSpoiler, isFalse);
});
test('false when >= 50% watched', () {
expect(_episode(viewOffset: 5000, duration: 10000).shouldHideSpoiler, isFalse);
expect(_episode(viewOffset: 8000, duration: 10000).shouldHideSpoiler, isFalse);
});
test('true when < 50% watched', () {
expect(_episode(viewOffset: 1000, duration: 10000).shouldHideSpoiler, isTrue);
expect(_episode(viewOffset: 4999, duration: 10000).shouldHideSpoiler, isTrue);
});
test('true when no progress at all (unwatched)', () {
expect(_episode().shouldHideSpoiler, isTrue);
expect(_episode(viewOffset: 0).shouldHideSpoiler, isTrue);
});
test('true when duration is missing', () {
expect(_episode(viewOffset: 500).shouldHideSpoiler, isTrue);
});
});
group('ContentTypeHelper', () {
test('isMusicContent / isVideoContent are case-insensitive', () {
expect(ContentTypeHelper.isMusicContent('ARTIST'), isTrue);
expect(ContentTypeHelper.isMusicContent('track'), isTrue);
expect(ContentTypeHelper.isMusicContent('movie'), isFalse);
expect(ContentTypeHelper.isVideoContent('MOVIE'), isTrue);
expect(ContentTypeHelper.isVideoContent('episode'), isTrue);
expect(ContentTypeHelper.isVideoContent('artist'), isFalse);
});
test('isMusicLibrary returns false for null and non-matching types', () {
expect(ContentTypeHelper.isMusicLibrary(null), isFalse);
});
});
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/formatters.dart';
void main() {
group('padNumber', () {
test('pads with leading zeros to given width', () {
expect(padNumber(5, 3), '005');
expect(padNumber(42, 2), '42');
expect(padNumber(7, 1), '7');
});
test('does not truncate numbers wider than width', () {
expect(padNumber(1234, 2), '1234');
});
});
group('ByteFormatter.formatBytes', () {
test('< 1 KB rendered as bytes', () {
expect(ByteFormatter.formatBytes(0), '0 B');
expect(ByteFormatter.formatBytes(1023), '1023 B');
});
test('1 KB boundary', () {
expect(ByteFormatter.formatBytes(1024), '1.0 KB');
});
test('< 1 MB rendered as KB', () {
expect(ByteFormatter.formatBytes(2048), '2.0 KB');
expect(ByteFormatter.formatBytes(1024 * 1024 - 1), '1024.0 KB');
});
test('1 MB boundary', () {
expect(ByteFormatter.formatBytes(1024 * 1024), '1.0 MB');
});
test('< 1 GB rendered as MB', () {
expect(ByteFormatter.formatBytes(500 * 1024 * 1024), '500.0 MB');
});
test('>= 1 GB rendered with 2 decimals by default', () {
expect(ByteFormatter.formatBytes(1024 * 1024 * 1024), '1.00 GB');
});
test('decimals override applies to KB/MB/GB branches', () {
expect(ByteFormatter.formatBytes(1536, decimals: 2), '1.50 KB');
expect(ByteFormatter.formatBytes(2 * 1024 * 1024, decimals: 0), '2 MB');
expect(ByteFormatter.formatBytes(1024 * 1024 * 1024, decimals: 0), '1 GB');
});
});
group('ByteFormatter.formatSpeed', () {
test('< 1 KB/s -> bytes per second, no decimals', () {
expect(ByteFormatter.formatSpeed(0), '0 B/s');
expect(ByteFormatter.formatSpeed(512), '512 B/s');
});
test('KB/s range', () {
expect(ByteFormatter.formatSpeed(1024), '1.0 KB/s');
expect(ByteFormatter.formatSpeed(10 * 1024), '10.0 KB/s');
});
test('MB/s range', () {
expect(ByteFormatter.formatSpeed(1024 * 1024.0), '1.0 MB/s');
expect(ByteFormatter.formatSpeed(5 * 1024 * 1024.0), '5.0 MB/s');
});
});
group('ByteFormatter.formatBitrate', () {
test('< 1000 kbps', () {
expect(ByteFormatter.formatBitrate(0), '0 kbps');
expect(ByteFormatter.formatBitrate(500), '500 kbps');
expect(ByteFormatter.formatBitrate(999), '999 kbps');
});
test('1000 kbps boundary crosses to Mbps', () {
expect(ByteFormatter.formatBitrate(1000), '1.0 Mbps');
expect(ByteFormatter.formatBitrate(2500), '2.5 Mbps');
});
});
group('formatDurationTimestamp', () {
test('zero duration', () {
expect(formatDurationTimestamp(Duration.zero), '0:00');
});
test('M:SS when < 1 hour', () {
expect(formatDurationTimestamp(const Duration(seconds: 5)), '0:05');
expect(formatDurationTimestamp(const Duration(minutes: 3, seconds: 7)), '3:07');
expect(formatDurationTimestamp(const Duration(minutes: 59, seconds: 59)), '59:59');
});
test('H:MM:SS when >= 1 hour', () {
expect(formatDurationTimestamp(const Duration(hours: 1, minutes: 0, seconds: 0)), '1:00:00');
expect(formatDurationTimestamp(const Duration(hours: 1, minutes: 23, seconds: 45)), '1:23:45');
});
test('negative duration is prefixed with -', () {
expect(formatDurationTimestamp(const Duration(seconds: -7)), '-0:07');
expect(formatDurationTimestamp(const Duration(hours: -1, minutes: -2, seconds: -3)), '-1:02:03');
});
});
group('toBulletedString', () {
test('joins with " · " separator', () {
expect(toBulletedString(['a', 'b', 'c']), 'a · b · c');
});
test('single-element list returns the element', () {
expect(toBulletedString(['only']), 'only');
});
test('empty list returns empty string', () {
expect(toBulletedString([]), '');
});
});
group('formatPlaybackRate', () {
test('1x formats as "1x" without normalAtOne', () {
expect(formatPlaybackRate(1.0), '1x');
});
test('1x formats as "Normal" when normalAtOne is true', () {
expect(formatPlaybackRate(1.0, normalAtOne: true), 'Normal');
// Within ±0.005 epsilon.
expect(formatPlaybackRate(1.004, normalAtOne: true), 'Normal');
expect(formatPlaybackRate(0.996, normalAtOne: true), 'Normal');
});
test('just outside epsilon renders numeric', () {
expect(formatPlaybackRate(1.006, normalAtOne: true), '1.01x');
expect(formatPlaybackRate(0.994, normalAtOne: true), '0.99x');
});
test('strips trailing zeros after decimal', () {
expect(formatPlaybackRate(2.0), '2x');
expect(formatPlaybackRate(1.5), '1.5x');
expect(formatPlaybackRate(1.25), '1.25x');
expect(formatPlaybackRate(1.1), '1.1x');
});
test('non-1 rate with normalAtOne still renders numeric', () {
expect(formatPlaybackRate(0.5, normalAtOne: true), '0.5x');
expect(formatPlaybackRate(2.0, normalAtOne: true), '2x');
});
});
group('formatSyncOffset', () {
test('< 10s: milliseconds with sign', () {
expect(formatSyncOffset(150), '+150ms');
expect(formatSyncOffset(-250), '-250ms');
expect(formatSyncOffset(0), '+0ms');
expect(formatSyncOffset(9999), '+9999ms');
});
test('>= 10s: decimal seconds', () {
expect(formatSyncOffset(10000), '+10.0s');
expect(formatSyncOffset(-15100), '-15.1s');
});
});
group('formatFullDate', () {
test('returns input unchanged when unparseable', () {
expect(formatFullDate('not-a-date'), 'not-a-date');
expect(formatFullDate(''), '');
});
test('does not throw for a valid ISO date', () {
// DateFormat may fall back to raw input if intl date symbols aren't
// initialised in the test runner — just verify no crash and string output.
expect(formatFullDate('2024-01-15'), isA<String>());
});
});
}
+30
View File
@@ -0,0 +1,30 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/future_extensions.dart';
void main() {
group('namedTimeout', () {
test('throws TimeoutException whose message includes operation name', () async {
final never = Completer<int>().future;
try {
await never.namedTimeout(const Duration(milliseconds: 10), operation: 'fetchThing');
fail('expected TimeoutException');
} on TimeoutException catch (e) {
expect(e.message, contains('fetchThing'));
expect(e.message, contains('timed out'));
expect(e.duration, const Duration(milliseconds: 10));
}
});
test('passes through the original value when it completes in time', () async {
final result = await Future.value(42).namedTimeout(const Duration(seconds: 1), operation: 'fast');
expect(result, 42);
});
test('propagates non-timeout errors unchanged', () async {
final errored = Future<int>.error(StateError('boom'));
expect(() => errored.namedTimeout(const Duration(seconds: 1), operation: 'op'), throwsA(isA<StateError>()));
});
});
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/global_key_utils.dart';
void main() {
group('buildGlobalKey', () {
test('joins with a colon', () {
expect(buildGlobalKey('server', '123'), 'server:123');
});
test('passes through empty components', () {
expect(buildGlobalKey('', '123'), ':123');
expect(buildGlobalKey('server', ''), 'server:');
expect(buildGlobalKey('', ''), ':');
});
});
group('parseGlobalKey', () {
test('parses simple key', () {
final result = parseGlobalKey('abc:42');
expect(result, isNotNull);
expect(result!.serverId, 'abc');
expect(result.ratingKey, '42');
});
test('returns null when no colon', () {
expect(parseGlobalKey('no-colon-here'), isNull);
expect(parseGlobalKey(''), isNull);
});
test('preserves colons inside ratingKey (uses first colon only)', () {
final result = parseGlobalKey('server:path:with:colons');
expect(result, isNotNull);
expect(result!.serverId, 'server');
expect(result.ratingKey, 'path:with:colons');
});
test('allows empty serverId', () {
final result = parseGlobalKey(':42');
expect(result, isNotNull);
expect(result!.serverId, '');
expect(result.ratingKey, '42');
});
test('allows empty ratingKey', () {
final result = parseGlobalKey('server:');
expect(result, isNotNull);
expect(result!.serverId, 'server');
expect(result.ratingKey, '');
});
});
test('round-trip build → parse returns original components', () {
for (final pair in const [('s1', '42'), ('serverXYZ', '/library/metadata/123'), ('', 'abc'), ('s', '')]) {
final built = buildGlobalKey(pair.$1, pair.$2);
final parsed = parseGlobalKey(built);
expect(parsed, isNotNull);
expect(parsed!.serverId, pair.$1);
expect(parsed.ratingKey, pair.$2);
}
});
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/json_utils.dart';
void main() {
group('flexibleInt', () {
test('parses int as-is', () {
expect(flexibleInt(42), 42);
expect(flexibleInt(0), 0);
expect(flexibleInt(-7), -7);
});
test('truncates double to int', () {
expect(flexibleInt(3.9), 3);
expect(flexibleInt(-3.9), -3);
});
test('parses numeric string', () {
expect(flexibleInt('123'), 123);
expect(flexibleInt('-45'), -45);
});
test('returns null for unparseable string', () {
expect(flexibleInt('abc'), isNull);
expect(flexibleInt('12abc'), isNull);
expect(flexibleInt(''), isNull);
});
test('returns null for null or unsupported types', () {
expect(flexibleInt(null), isNull);
expect(flexibleInt(true), isNull);
expect(flexibleInt(<int>[1]), isNull);
expect(flexibleInt(<String, Object>{'a': 1}), isNull);
});
});
group('flexibleBool', () {
test('returns bool as-is', () {
expect(flexibleBool(true), isTrue);
expect(flexibleBool(false), isFalse);
});
test('maps 1 to true, other ints to false', () {
expect(flexibleBool(1), isTrue);
expect(flexibleBool(0), isFalse);
expect(flexibleBool(2), isFalse);
expect(flexibleBool(-1), isFalse);
});
test("maps '1' string to true, other strings to false", () {
expect(flexibleBool('1'), isTrue);
expect(flexibleBool('0'), isFalse);
expect(flexibleBool('true'), isFalse);
expect(flexibleBool(''), isFalse);
});
test('returns false for null and unsupported types', () {
expect(flexibleBool(null), isFalse);
expect(flexibleBool(1.0), isFalse);
expect(flexibleBool(<String, Object>{}), isFalse);
});
});
group('flexibleDouble', () {
test('parses num as double', () {
expect(flexibleDouble(1.5), 1.5);
expect(flexibleDouble(2), 2.0);
expect(flexibleDouble(0), 0.0);
});
test('parses numeric string', () {
expect(flexibleDouble('3.14'), 3.14);
expect(flexibleDouble('-2.5'), -2.5);
expect(flexibleDouble('7'), 7.0);
});
test('returns null for unparseable string', () {
expect(flexibleDouble('abc'), isNull);
expect(flexibleDouble(''), isNull);
});
test('returns null for null and unsupported types', () {
expect(flexibleDouble(null), isNull);
expect(flexibleDouble(true), isNull);
expect(flexibleDouble(<int>[1]), isNull);
});
});
group('readStringField', () {
test('coerces int to String', () {
expect(readStringField({'x': 42}, 'x'), '42');
});
test('coerces double to String', () {
expect(readStringField({'x': 3.14}, 'x'), '3.14');
});
test('leaves String alone', () {
expect(readStringField({'x': 'hello'}, 'x'), 'hello');
});
test('returns null for missing key', () {
expect(readStringField({'x': 1}, 'y'), isNull);
});
test('returns null for null value', () {
expect(readStringField({'x': null}, 'x'), isNull);
});
});
group('flexibleList', () {
test('passes list through unchanged', () {
final input = <dynamic>[1, 2, 3];
expect(flexibleList(input), same(input));
});
test('wraps single Map in a List', () {
final item = <String, Object>{'key': 'value'};
expect(flexibleList(item), [item]);
});
test('wraps single String in a List', () {
expect(flexibleList('solo'), ['solo']);
});
test('returns null for null input', () {
expect(flexibleList(null), isNull);
});
test('handles empty list', () {
expect(flexibleList(<dynamic>[]), <dynamic>[]);
});
});
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/layout_constants.dart';
void main() {
group('ScreenBreakpoints boundaries', () {
test('isMobile: strict < 600', () {
expect(ScreenBreakpoints.isMobile(0), isTrue);
expect(ScreenBreakpoints.isMobile(599.9), isTrue);
expect(ScreenBreakpoints.isMobile(600), isFalse);
});
test('isTablet: 600 ≤ w < 1200', () {
expect(ScreenBreakpoints.isTablet(599.9), isFalse);
expect(ScreenBreakpoints.isTablet(600), isTrue);
expect(ScreenBreakpoints.isTablet(899.9), isTrue);
expect(ScreenBreakpoints.isTablet(900), isTrue);
expect(ScreenBreakpoints.isTablet(1199.9), isTrue);
expect(ScreenBreakpoints.isTablet(1200), isFalse);
});
test('isWideTablet: 900 ≤ w < 1200', () {
expect(ScreenBreakpoints.isWideTablet(899.9), isFalse);
expect(ScreenBreakpoints.isWideTablet(900), isTrue);
expect(ScreenBreakpoints.isWideTablet(1199.9), isTrue);
expect(ScreenBreakpoints.isWideTablet(1200), isFalse);
});
test('isDesktop: 1200 ≤ w < 1600', () {
expect(ScreenBreakpoints.isDesktop(1199.9), isFalse);
expect(ScreenBreakpoints.isDesktop(1200), isTrue);
expect(ScreenBreakpoints.isDesktop(1599.9), isTrue);
expect(ScreenBreakpoints.isDesktop(1600), isFalse);
});
test('isLargeDesktop: w ≥ 1600', () {
expect(ScreenBreakpoints.isLargeDesktop(1599.9), isFalse);
expect(ScreenBreakpoints.isLargeDesktop(1600), isTrue);
expect(ScreenBreakpoints.isLargeDesktop(10000), isTrue);
});
test('isDesktopOrLarger: w ≥ 1200', () {
expect(ScreenBreakpoints.isDesktopOrLarger(1199.9), isFalse);
expect(ScreenBreakpoints.isDesktopOrLarger(1200), isTrue);
expect(ScreenBreakpoints.isDesktopOrLarger(5000), isTrue);
});
test('isWideTabletOrLarger: w ≥ 900', () {
expect(ScreenBreakpoints.isWideTabletOrLarger(899.9), isFalse);
expect(ScreenBreakpoints.isWideTabletOrLarger(900), isTrue);
expect(ScreenBreakpoints.isWideTabletOrLarger(5000), isTrue);
});
test('constant values match expected thresholds', () {
expect(ScreenBreakpoints.mobile, 600);
expect(ScreenBreakpoints.tablet, 600);
expect(ScreenBreakpoints.wideTablet, 900);
expect(ScreenBreakpoints.desktop, 1200);
expect(ScreenBreakpoints.largeDesktop, 1600);
});
test('partitioning: every width matches exactly one of mobile/tablet/desktop/largeDesktop', () {
for (final w in const [0.0, 300, 599.9, 600, 899.9, 1199.9, 1200, 1599.9, 1600, 2500]) {
final matches = [
ScreenBreakpoints.isMobile(w.toDouble()),
ScreenBreakpoints.isTablet(w.toDouble()) && !ScreenBreakpoints.isDesktopOrLarger(w.toDouble()),
ScreenBreakpoints.isDesktop(w.toDouble()),
ScreenBreakpoints.isLargeDesktop(w.toDouble()),
].where((b) => b).length;
expect(matches, 1, reason: 'width $w should match exactly one tier');
}
});
});
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/plex_cache_parser.dart';
void main() {
group('PlexCacheParser.extractMetadataList', () {
test('returns null for null input', () {
expect(PlexCacheParser.extractMetadataList(null), isNull);
});
test('returns null when MediaContainer missing', () {
expect(PlexCacheParser.extractMetadataList({}), isNull);
});
test('returns null when Metadata key missing', () {
expect(PlexCacheParser.extractMetadataList({'MediaContainer': {}}), isNull);
});
test('returns list when present', () {
final list = [
{'ratingKey': '1'},
{'ratingKey': '2'},
];
final result = PlexCacheParser.extractMetadataList({
'MediaContainer': {'Metadata': list},
});
expect(result, equals(list));
});
test('throws TypeError when Metadata is present but not a list (current contract)', () {
expect(
() => PlexCacheParser.extractMetadataList({
'MediaContainer': {'Metadata': 'not-a-list'},
}),
throwsA(isA<TypeError>()),
);
});
});
group('PlexCacheParser.extractFirstMetadata', () {
test('returns null for null input', () {
expect(PlexCacheParser.extractFirstMetadata(null), isNull);
});
test('returns null for empty Metadata', () {
expect(
PlexCacheParser.extractFirstMetadata({
'MediaContainer': {'Metadata': []},
}),
isNull,
);
});
test('returns first map when present', () {
final first = <String, dynamic>{'ratingKey': '1'};
final second = <String, dynamic>{'ratingKey': '2'};
final result = PlexCacheParser.extractFirstMetadata({
'MediaContainer': {
'Metadata': [first, second],
},
});
expect(result, equals(first));
});
});
group('PlexCacheParser.extractChapters', () {
test('returns null for null input', () {
expect(PlexCacheParser.extractChapters(null), isNull);
});
test('returns null when no metadata', () {
expect(
PlexCacheParser.extractChapters({
'MediaContainer': {'Metadata': []},
}),
isNull,
);
});
test('returns null when first metadata has no Chapter key', () {
expect(
PlexCacheParser.extractChapters({
'MediaContainer': {
'Metadata': [
<String, dynamic>{'ratingKey': '1'},
],
},
}),
isNull,
);
});
test('returns chapter list when present', () {
final chapters = [
{'tag': 'Chapter 1'},
{'tag': 'Chapter 2'},
];
final result = PlexCacheParser.extractChapters({
'MediaContainer': {
'Metadata': [
{'ratingKey': '1', 'Chapter': chapters},
],
},
});
expect(result, equals(chapters));
});
});
}
+87
View File
@@ -0,0 +1,87 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/utils/plex_http_exception.dart';
void main() {
group('PlexHttpException.from', () {
final uri = Uri.parse('http://example/api/thing');
test('returns same instance for PlexHttpException input (no re-wrap)', () {
final original = PlexHttpException(type: PlexHttpErrorType.connectionError, message: 'boom', requestUri: uri);
final result = PlexHttpException.from(original, uri: uri);
expect(identical(result, original), isTrue);
});
test('TimeoutException -> connectionTimeout', () {
final tm = TimeoutException('took too long', const Duration(seconds: 1));
final result = PlexHttpException.from(tm, uri: uri);
expect(result.type, PlexHttpErrorType.connectionTimeout);
expect(result.message, 'took too long');
expect(result.requestUri, uri);
});
test('SocketException -> connectionError', () {
final result = PlexHttpException.from(const SocketException('refused'), uri: uri);
expect(result.type, PlexHttpErrorType.connectionError);
expect(result.message, 'refused');
expect(result.requestUri, uri);
});
test('HttpException -> connectionError', () {
final result = PlexHttpException.from(const HttpException('bad header'), uri: uri);
expect(result.type, PlexHttpErrorType.connectionError);
expect(result.message, 'bad header');
expect(result.requestUri, uri);
});
test('http.ClientException -> connectionError, prefers error.uri over passed uri', () {
final clientUri = Uri.parse('http://other/path');
final ex = http.ClientException('bad', clientUri);
final result = PlexHttpException.from(ex, uri: uri);
expect(result.type, PlexHttpErrorType.connectionError);
expect(result.message, 'bad');
expect(result.requestUri, clientUri);
});
test('http.ClientException with null uri falls back to passed uri', () {
final ex = http.ClientException('bad');
final result = PlexHttpException.from(ex, uri: uri);
expect(result.requestUri, uri);
});
test('RequestAbortedException maps to cancelled (not connectionError) despite extending ClientException', () {
final abortUri = Uri.parse('http://abort/x');
final ex = http.RequestAbortedException(abortUri);
final result = PlexHttpException.from(ex, uri: uri);
expect(result.type, PlexHttpErrorType.cancelled);
expect(result.requestUri, abortUri);
});
test('RequestAbortedException with no uri falls back to passed uri', () {
final ex = http.RequestAbortedException();
final result = PlexHttpException.from(ex, uri: uri);
expect(result.type, PlexHttpErrorType.cancelled);
expect(result.requestUri, uri);
});
test('unknown error -> unknown type with toString() message', () {
final result = PlexHttpException.from(Exception('weird'), uri: uri);
expect(result.type, PlexHttpErrorType.unknown);
expect(result.message, contains('weird'));
expect(result.requestUri, uri);
});
test('no uri passed -> requestUri is null for non-ClientException', () {
final result = PlexHttpException.from(TimeoutException('t'));
expect(result.requestUri, isNull);
});
test('toString includes type and message', () {
final e = PlexHttpException(type: PlexHttpErrorType.cancelled, message: 'halt');
expect(e.toString(), 'PlexHttpException(cancelled: halt)');
});
});
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/plex_url_helper.dart';
void main() {
group('withPlexToken', () {
test('appends with ? when no existing query', () {
expect('/library/metadata/1'.withPlexToken('abc'), '/library/metadata/1?X-Plex-Token=abc');
});
test('appends with & when query already present', () {
expect('/library/metadata/1?type=1'.withPlexToken('abc'), '/library/metadata/1?type=1&X-Plex-Token=abc');
});
test('returns original URL when token is null', () {
expect('/library/metadata/1'.withPlexToken(null), '/library/metadata/1');
});
test('returns original URL when token is empty', () {
expect('/library/metadata/1'.withPlexToken(''), '/library/metadata/1');
});
test('treats trailing ? (no params yet) as already having query', () {
expect('/path?'.withPlexToken('tk'), '/path?&X-Plex-Token=tk');
});
});
group('toPlexUrl', () {
test('prefixes base URL and appends token', () {
expect(
'/library/metadata/1'.toPlexUrl('http://server:32400', 'abc'),
'http://server:32400/library/metadata/1?X-Plex-Token=abc',
);
});
test('prefixes base URL and skips token when null', () {
expect('/library/metadata/1'.toPlexUrl('http://server:32400', null), 'http://server:32400/library/metadata/1');
});
test('uses & when base URL already contains ?', () {
expect('?foo=bar'.toPlexUrl('http://s', 'tk'), 'http://s?foo=bar&X-Plex-Token=tk');
});
});
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/rating_utils.dart';
void main() {
group('parseRatingImage - null/missing', () {
test('returns null when imageUri is null', () {
expect(parseRatingImage(null, 7.5), isNull);
});
test('returns null when value is null', () {
expect(parseRatingImage('imdb://title/tt123', null), isNull);
});
test('returns null for unknown scheme', () {
expect(parseRatingImage('unknown://foo', 5.0), isNull);
});
});
group('parseRatingImage - Rotten Tomatoes', () {
test('ripe maps to rt_fresh with percent', () {
final info = parseRatingImage('rottentomatoes://image.rating.ripe', 7.5);
expect(info, isNotNull);
expect(info!.assetPath, 'assets/rating_icons/rt_fresh.svg');
expect(info.formattedValue, '75%');
});
test('rotten maps to rt_rotten', () {
final info = parseRatingImage('rottentomatoes://image.rating.rotten', 3.2);
expect(info, isNotNull);
expect(info!.assetPath, 'assets/rating_icons/rt_rotten.svg');
expect(info.formattedValue, '32%');
});
test('upright maps to rt_upright', () {
final info = parseRatingImage('rottentomatoes://image.rating.upright', 8.8);
expect(info!.assetPath, 'assets/rating_icons/rt_upright.svg');
expect(info.formattedValue, '88%');
});
test('spilled maps to rt_spilled', () {
final info = parseRatingImage('rottentomatoes://image.rating.spilled', 2.0);
expect(info!.assetPath, 'assets/rating_icons/rt_spilled.svg');
expect(info.formattedValue, '20%');
});
test('unknown RT suffix returns null', () {
expect(parseRatingImage('rottentomatoes://image.rating.green', 5.0), isNull);
});
test('percent rounds to whole number', () {
final info = parseRatingImage('rottentomatoes://image.rating.ripe', 7.57);
expect(info!.formattedValue, '76%');
});
});
group('parseRatingImage - IMDb', () {
test('formats with one decimal', () {
final info = parseRatingImage('imdb://title/tt123', 7.5);
expect(info!.assetPath, 'assets/rating_icons/imdb.svg');
expect(info.formattedValue, '7.5');
});
test('formats to one decimal (truncation follows toStringAsFixed semantics)', () {
final info = parseRatingImage('imdb://title', 7.25);
expect(info!.formattedValue, anyOf('7.2', '7.3'));
});
});
group('parseRatingImage - TMDB', () {
test('converts value*10 to percent', () {
final info = parseRatingImage('themoviedb://foo', 6.8);
expect(info!.assetPath, 'assets/rating_icons/tmdb.svg');
expect(info.formattedValue, '68%');
});
});
group('isRottenTomatoes', () {
test('matches rottentomatoes:// scheme', () {
expect(isRottenTomatoes('rottentomatoes://image.rating.ripe'), isTrue);
expect(isRottenTomatoes('rottentomatoes://anything'), isTrue);
});
test('false for null', () {
expect(isRottenTomatoes(null), isFalse);
});
test('false for other schemes', () {
expect(isRottenTomatoes('imdb://title'), isFalse);
expect(isRottenTomatoes('themoviedb://foo'), isFalse);
expect(isRottenTomatoes(''), isFalse);
});
});
}