import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/jellyfin/home_rows.dart'; import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/shell/library_link.dart'; import 'package:watch_together/src/shell/library_tab.dart'; final now = DateTime.utc(2026, 10, 8, 12); DateTime daysAgo(num d) => now.subtract(Duration(minutes: (d * 24 * 60).round())); JellyfinItem ep(String id, String series, {DateTime? played, int position = 0, int? season, int? number, bool done = false}) => JellyfinItem( id: id, name: id, type: 'Episode', seriesId: series, seriesName: 'Series $series', parentIndexNumber: season, indexNumber: number, userData: JellyfinUserData(played: done, playbackPositionTicks: position, lastPlayedDate: played), ); JellyfinItem movie(String id, {DateTime? played, int position = 0}) => JellyfinItem( id: id, name: id, type: 'Movie', userData: JellyfinUserData(playbackPositionTicks: position, lastPlayedDate: played), ); List ids(List l) => [for (final i in l) i.id]; void main() { group('Continue row', () { test('one card per series: resume point and next up merged', () { final out = mergeContinue( resume: [ep('a3', 'A', played: daysAgo(1), position: 5)], nextUp: [ep('a4', 'A'), ep('b2', 'B')], recentlyPlayed: [ep('b1', 'B', played: daysAgo(2), done: true)], now: now, ); expect(ids(out), ['a3', 'b2']); }); test('the resume item only when it is the latest activity in its series', () { // Resumed E3 three days ago, then finished E4 yesterday: next up wins. final out = mergeContinue( resume: [ep('a3', 'A', played: daysAgo(3), position: 5)], nextUp: [ep('a5', 'A')], recentlyPlayed: [ep('a4', 'A', played: daysAgo(1), done: true)], now: now, ); expect(ids(out), ['a5']); // No next up (last episode): the resume point stays. expect( ids(mergeContinue( resume: [ep('a3', 'A', played: daysAgo(3), position: 5)], nextUp: const [], recentlyPlayed: [ep('a4', 'A', played: daysAgo(1), done: true)], now: now, )), ['a3']); }); test('ordered by when the series was last played; newest resume point of a series', () { final out = mergeContinue( resume: [ ep('a1', 'A', played: daysAgo(5), position: 5), movie('m', played: daysAgo(0.5), position: 5), ep('a2', 'A', played: daysAgo(4), position: 5), ], nextUp: [ep('c1', 'C'), ep('b1', 'B')], recentlyPlayed: [ ep('b0', 'B', played: daysAgo(2), done: true), ep('c0', 'C', played: daysAgo(3), done: true), ], now: now, ); expect(ids(out), ['m', 'b1', 'c1', 'a2']); }); test('series untouched for over 60 days drop out, unless nothing else is left', () { final stale = ep('old', 'O', played: daysAgo(90), position: 5); final unknown = ep('u1', 'U'); // next up with no play date known expect( ids(mergeContinue( resume: [stale, ep('a1', 'A', played: daysAgo(10), position: 5)], nextUp: [unknown], now: now)), ['a1']); expect(ids(mergeContinue(resume: [stale], nextUp: [unknown], now: now)), ['old', 'u1']); expect(mergeContinue(resume: const [], nextUp: const [], now: now), isEmpty); }); test('a dismissed series stays out until it is played again', () { List run(DateTime playedAt) => ids(mergeContinue( resume: [ep('a1', 'A', played: playedAt, position: 5), ep('b1', 'B', played: daysAgo(1), position: 5)], nextUp: const [], dismissed: {'A': daysAgo(2)}, now: now, )); expect(run(daysAgo(3)), ['b1']); expect(run(daysAgo(0.5)), ['a1', 'b1']); }); test('continueKey: the series, else the item', () { expect(continueKey(ep('e', 'S')), 'S'); expect(continueKey(movie('m')), 'm'); }); }); group('Latest row', () { JellyfinItem added(String id, {String? series, String type = 'Episode', DateTime? aired, DateTime? at, int? n}) => JellyfinItem( id: id, name: id, type: type, seriesId: series, seriesName: series == null ? null : 'Show $series', seriesPrimaryImageTag: series == null ? null : 'tag-$series', parentIndexNumber: series == null ? null : 1, indexNumber: n, premiereDate: aired, dateCreated: at, ); test('re-releases: added more than 30 days after airing', () { expect(isReRelease(added('x', aired: daysAgo(400), at: daysAgo(1))), isTrue); expect(isReRelease(added('x', aired: daysAgo(20), at: daysAgo(1))), isFalse); expect(isReRelease(added('x', aired: null, at: daysAgo(1))), isFalse); expect(isReRelease(added('x', aired: daysAgo(400), at: null)), isFalse); // Added before it aired (pre-release): not a re-release. expect(isReRelease(added('x', aired: daysAgo(1), at: daysAgo(400))), isFalse); }); test('episodes become series cards: one new shows S1E1, more show "N new"', () { final cards = latestCards([ added('a2', series: 'A', n: 2, aired: daysAgo(1), at: daysAgo(0)), added('m', type: 'Movie', aired: daysAgo(3), at: daysAgo(1)), added('a1', series: 'A', n: 1, aired: daysAgo(8), at: daysAgo(1)), added('b1', series: 'B', n: 1, aired: daysAgo(2), at: daysAgo(2)), ]); expect([for (final c in cards) c.display.name], ['Show A', 'm', 'Show B']); expect([for (final c in cards) c.subtitle], ['2 new', anything, 'S1E1']); // Two new: the card is the series; one new: selecting it is the episode. expect(cards[0].item.id, 'A'); expect(cards[0].item.kind, JellyfinItemKind.series); expect(cards[2].item.id, 'b1'); expect(cards[2].display.image(JellyfinImageType.primary)!.itemId, 'B'); expect(cards[2].display.image(JellyfinImageType.primary)!.tag, 'tag-B'); }); test('re-releases are left out when hidden, kept otherwise', () { final items = [ added('new', series: 'A', n: 5, aired: daysAgo(1), at: daysAgo(0)), added('old1', series: 'R', n: 1, aired: daysAgo(3000), at: daysAgo(0)), added('old2', series: 'R', n: 2, aired: daysAgo(3000), at: daysAgo(0)), ]; expect([for (final c in latestCards(items)) c.display.name], ['Show A']); expect([for (final c in latestCards(items, hideReReleases: false)) c.display.name], ['Show A', 'Show R']); }); test('grouped series from the server keep their "N new"', () { const s = JellyfinItem(id: 's', name: 'Grouped', type: 'Series', isFolder: true, childCount: 4); expect(latestCards([s]).single.subtitle, '4 new'); }); }); group('Link source', () { RecentLink link(String url, [int day = 1]) => RecentLink(url: url, title: url, at: DateTime.utc(2026, 1, day)); test('recent links: newest first, deduped, at most 12, round-trip', () { var list = []; for (var i = 0; i < 15; i++) { list = pushRecentLink(list, link('https://x/$i')); } expect(list, hasLength(maxRecentLinks)); expect(list.first.url, 'https://x/14'); list = pushRecentLink(list, link('https://x/10', 9)); expect(list.first.url, 'https://x/10'); expect(list.where((l) => l.url == 'https://x/10'), hasLength(1)); final back = decodeRecentLinks(encodeRecentLinks(list)); expect([for (final l in back) l.url], [for (final l in list) l.url]); expect(back.first.at.isAtSameMomentAs(DateTime.utc(2026, 1, 9)), isTrue); expect(decodeRecentLinks('nope'), isEmpty); expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv'); }); test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async { Uri? asked; final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async { asked = uri; return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'}); }); expect(asked!.host, 'www.youtube.com'); expect(asked!.path, '/oembed'); expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'}); expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com ยท Chan', 'https://i.ytimg/x.jpg')); final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1', get: (_) => throw StateError('no lookup for plain links')); expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null)); final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline')); expect((down!.title, down.subtitle), ('abc', 'youtu.be')); expect(await previewLink('not a link'), isNull); }); test('ago text', () { final t = DateTime.utc(2026, 10, 8); expect(agoText(t.subtract(const Duration(hours: 3)), t), 'today'); expect(agoText(t.subtract(const Duration(days: 1)), t), 'yesterday'); expect(agoText(t.subtract(const Duration(days: 3)), t), '3 days ago'); expect(agoText(t.subtract(const Duration(days: 9)), t), 'last week'); expect(agoText(t.subtract(const Duration(days: 40)), t), '5 weeks ago'); }); }); test('now playing file: only a plain file named like the last one put up', () { expect(nowPlayingFile({'name': 'b.mkv', 'size': 1}, '/v/b.mkv'), '/v/b.mkv'); expect(nowPlayingFile({'name': 'b.mkv', 'url': 'http://x/b.mkv'}, '/v/b.mkv'), isNull); expect(nowPlayingFile({'name': 'b.mkv', 'jellyfin': {}}, '/v/b.mkv'), isNull); expect(nowPlayingFile({'name': 'c.mkv'}, '/v/b.mkv'), isNull); expect(nowPlayingFile(null, '/v/b.mkv'), isNull); expect(nowPlayingFile({'name': 'b.mkv'}, r'C:\v\b.mkv'), r'C:\v\b.mkv'); }); test('controller: a Jellyfin pick selects its card', () { final c = MediaPickerController(); addTearDown(c.dispose); const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't'); c.pickJellyfin(m); expect(c.source, MediaKind.jellyfin); expect(c.selected!.itemId, 'i'); c.select(null); expect(c.selected, isNull); expect(c.jellyfin, m); }); }