Files
sync_replay_project/apps/desktop/test/library_rows_test.dart
T
foxyzandClaude Opus 5.5 d092ea371a 1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag
Library: source switch, selectable cards with a details panel, one merged
Continue row, Latest grouped per series without re-releases, Link page with
previews and recent links, Files embedded. Player: gear popover in both
designs, progress line while controls hide, live hwdec. Settings rebuilt as
cards. State marks the host member (proto stays 9, unreleased); hosting
shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps
the outgoing view's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:12 +03:00

243 lines
10 KiB
Dart

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<String> ids(List<JellyfinItem> 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<String> 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 = <RecentLink>[];
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);
});
}