diff --git a/test/models/plex_metadata_test.dart b/test/models/plex_metadata_test.dart new file mode 100644 index 00000000..91049e56 --- /dev/null +++ b/test/models/plex_metadata_test.dart @@ -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'); + }); + }); +} diff --git a/test/models/plex_sort_test.dart b/test/models/plex_sort_test.dart new file mode 100644 index 00000000..7a6b4f70 --- /dev/null +++ b/test/models/plex_sort_test.dart @@ -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); + }); + }); +} diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart new file mode 100644 index 00000000..084553cd --- /dev/null +++ b/test/services/settings_service_test.dart @@ -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); + }); + }); +} diff --git a/test/utils/base_notifier_test.dart b/test/utils/base_notifier_test.dart new file mode 100644 index 00000000..dac601c6 --- /dev/null +++ b/test/utils/base_notifier_test.dart @@ -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 {} + +void main() { + group('BaseNotifier', () { + test('single listener receives events', () async { + final n = _IntNotifier(); + final received = []; + final sub = n.stream.listen(received.add); + + n.notify(1); + n.notify(2); + n.notify(3); + await Future.delayed(Duration.zero); + + expect(received, [1, 2, 3]); + await sub.cancel(); + n.dispose(); + }); + + test('broadcasts to multiple listeners', () async { + final n = _IntNotifier(); + final a = []; + final b = []; + final subA = n.stream.listen(a.add); + final subB = n.stream.listen(b.add); + + n.notify(10); + n.notify(20); + await Future.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 = []; + final sub = n.stream.listen(received.add); + n.notify(8); + await Future.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(); + final sub = n.stream.listen((_) {}, onDone: doneCompleter.complete); + n.dispose(); + await doneCompleter.future.timeout(const Duration(seconds: 1)); + await sub.cancel(); + }); + }); +} diff --git a/test/utils/codec_utils_test.dart b/test/utils/codec_utils_test.dart new file mode 100644 index 00000000..3f966637 --- /dev/null +++ b/test/utils/codec_utils_test.dart @@ -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'); + }); + }); +} diff --git a/test/utils/content_utils_test.dart b/test/utils/content_utils_test.dart new file mode 100644 index 00000000..e592ce0e --- /dev/null +++ b/test/utils/content_utils_test.dart @@ -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); + }); + }); +} diff --git a/test/utils/formatters_test.dart b/test/utils/formatters_test.dart new file mode 100644 index 00000000..155fb8af --- /dev/null +++ b/test/utils/formatters_test.dart @@ -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()); + }); + }); +} diff --git a/test/utils/future_extensions_test.dart b/test/utils/future_extensions_test.dart new file mode 100644 index 00000000..67b7a2de --- /dev/null +++ b/test/utils/future_extensions_test.dart @@ -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().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.error(StateError('boom')); + expect(() => errored.namedTimeout(const Duration(seconds: 1), operation: 'op'), throwsA(isA())); + }); + }); +} diff --git a/test/utils/global_key_utils_test.dart b/test/utils/global_key_utils_test.dart new file mode 100644 index 00000000..3881b0ab --- /dev/null +++ b/test/utils/global_key_utils_test.dart @@ -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); + } + }); +} diff --git a/test/utils/json_utils_test.dart b/test/utils/json_utils_test.dart new file mode 100644 index 00000000..79aca55b --- /dev/null +++ b/test/utils/json_utils_test.dart @@ -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([1]), isNull); + expect(flexibleInt({'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({}), 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([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 = [1, 2, 3]; + expect(flexibleList(input), same(input)); + }); + + test('wraps single Map in a List', () { + final item = {'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([]), []); + }); + }); +} diff --git a/test/utils/layout_constants_test.dart b/test/utils/layout_constants_test.dart new file mode 100644 index 00000000..a8e5e418 --- /dev/null +++ b/test/utils/layout_constants_test.dart @@ -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'); + } + }); + }); +} diff --git a/test/utils/plex_cache_parser_test.dart b/test/utils/plex_cache_parser_test.dart new file mode 100644 index 00000000..0fbc4a24 --- /dev/null +++ b/test/utils/plex_cache_parser_test.dart @@ -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()), + ); + }); + }); + + 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 = {'ratingKey': '1'}; + final second = {'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': [ + {'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)); + }); + }); +} diff --git a/test/utils/plex_http_exception_test.dart b/test/utils/plex_http_exception_test.dart new file mode 100644 index 00000000..2e6cc393 --- /dev/null +++ b/test/utils/plex_http_exception_test.dart @@ -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)'); + }); + }); +} diff --git a/test/utils/plex_url_helper_test.dart b/test/utils/plex_url_helper_test.dart new file mode 100644 index 00000000..ebec8523 --- /dev/null +++ b/test/utils/plex_url_helper_test.dart @@ -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'); + }); + }); +} diff --git a/test/utils/rating_utils_test.dart b/test/utils/rating_utils_test.dart new file mode 100644 index 00000000..26bc9c68 --- /dev/null +++ b/test/utils/rating_utils_test.dart @@ -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); + }); + }); +}