Files
sync_replay_project/apps/desktop/test/jellyfin_session_test.dart
T
foxyzandClaude Opus 5.5 4d00b7455e
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m7s
ci / updater-windows (push) Failing after 6m4s
Restructure step 3: your server, Invite, version gate, AppGraph
- One server per app run (loopback, no password); Invite attaches an
  all-interfaces listener to the same server and Stop sharing detaches
  it (guests told with 4016); members, media and Up next never reset.
  No solo mode: you're home on your own server unless in a friend's.
- Navigation: none / yours / friend; a join passes a version gate on
  /health first (clear "they/you need to update", update offer).
- Invite as a value (host, port, name, password flag, version, fp
  reserved); addresses filtered (loopback, link-local, 198.18/15,
  bridges) and ranked; Sharing chip, Copy invite, Stop sharing.
- Alone looks like a plain player (alone replaces solo).
- AppGraph builds and disposes systems; widgets use AppScope;
  SessionState deleted; a test caps the remaining singletons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:52:52 +03:00

560 lines
27 KiB
Dart

// restructure: rewrite in step 6 (What's next + Watch tracking): the
// "progress" and "episodes" groups (reporting, auto-advance swapper).
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:starsync/src/jellyfin/browser.dart';
import 'package:starsync/src/jellyfin/progress.dart';
import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle;
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
const episodeId = 'b18826be994070a79f9b52bc0f66f052'; // Black Summoner S1E6
const nextEpisodeId = '74c50bdbf83e8fef231dd7710058e197'; // S1E7
const seriesId = 'e9aec9c14a1fcab775111844112b4cf7';
const seasonId = 'fb0b77cfda15445f9319ed9d753dba23';
const playSessionId = 'fc428dbbeb9c46a7885fa053bd46f7e7';
const episodeRef = JellyfinRef(
serverId: fixtureServerId, itemId: episodeId, title: 'Rice and Thieves and Heroes');
/// The fake plus playback: PlaybackInfo, the Black Summoner episodes and
/// the three Sessions reports (recorded with their bodies).
FakeJellyfin playbackFake() {
final fake = FakeJellyfin();
fake.fixture('POST', '/Items/$episodeId/PlaybackInfo', 'playback_info');
fake.on('GET', '/Shows/$seriesId/Episodes',
(_, _) => JellyfinHttpResponse(200, jellyfinFixture('bs_episodes')));
for (final path in ['/Sessions/Playing', '/Sessions/Playing/Progress', '/Sessions/Playing/Stopped']) {
fake.on('POST', path, (_, body) {
reports.add((path: path, body: jsonDecode(body!) as Map<String, dynamic>));
return const JellyfinHttpResponse(204, '');
});
}
fake.on('POST', '/UserPlayedItems/$episodeId', (_, _) {
reports.add((path: '/UserPlayedItems', body: const {}));
return const JellyfinHttpResponse(200, '{"Played":true}');
});
fake.on('POST', '/UserItems/$episodeId/UserData', (_, body) {
reports.add((path: '/UserData', body: jsonDecode(body!) as Map<String, dynamic>));
return const JellyfinHttpResponse(200, '{}');
});
return fake;
}
final reports = <({String path, Map<String, dynamic> body})>[];
JellyfinMediaSource fixtureSource() => JellyfinPlaybackInfo.fromJson(
jsonDecode(jellyfinFixture('playback_info')) as Map<String, dynamic>)
.primarySource!;
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool gone = false}) =>
MemberView(
id: id,
name: name,
ready: true,
inSet: true,
buffered: 10,
presence: gone ? Presence.gone : Presence.watching,
jf: jf);
void main() {
setUp(reports.clear);
group('resolve a ref', () {
test('own account: PlaybackInfo with its own user, static stream, token in the header',
() async {
final (accounts, fake) = await linkedAccounts(http: playbackFake());
final plan = await resolveJellyfinRef(accounts, episodeRef,
relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId');
expect(plan, isA<OwnPlay>());
plan as OwnPlay;
expect(plan.jfAccess, isTrue);
expect(plan.info.playSessionId, playSessionId);
expect(plan.source.id, episodeId);
expect(plan.item?.seriesId, seriesId);
final info = fake.requests.firstWhere((r) => r.uri.path.endsWith('/PlaybackInfo'));
expect(info.uri.queryParameters['userId'], fixtureUserId);
expect(info.headers['Authorization'], contains('Token="TEST_ACCESS_TOKEN"'));
final url = Uri.parse(plan.url);
expect(url.path, '/Videos/$episodeId/stream');
expect(url.queryParameters['static'], 'true');
expect(url.queryParameters.containsKey('api_key'), isFalse, reason: 'token in a header only');
expect(plan.headers['Authorization'], accounts.clientInfo.authorizationHeader('TEST_ACCESS_TOKEN'));
});
test('no account: the relay URL when the server relays', () async {
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new);
final plan = await resolveJellyfinRef(accounts, episodeRef,
relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId');
expect(plan, isA<RelayPlay>());
expect((plan as RelayPlay).url, 'http://h:1/j/default/t/$episodeId');
expect(plan.jfAccess, isFalse);
});
test('neither: blocked with a clear message, never a silent stall', () async {
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new);
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
expect(plan, isA<BlockedPlay>());
expect((plan as BlockedPlay).message,
'No Jellyfin account here for that Jellyfin server, and nobody is passing it on');
expect(plan.jfAccess, isNull);
});
test('a 401 marks the account expired and falls back to the relay', () async {
final fake = playbackFake()
..on('POST', '/Items/$episodeId/PlaybackInfo', (_, _) => const JellyfinHttpResponse(401, ''));
final (accounts, _) = await linkedAccounts(http: fake);
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: true, relayUrl: 'http://r');
expect(plan, isA<RelayPlay>());
expect(accounts.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
final blocked = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
expect((blocked as BlockedPlay).message, contains('for Example Server'));
expect(blocked.message, contains('sign-in expired'));
});
test('relay URL is built like the /m one', () {
expect(relayUrlFor('10.0.0.2', 8080, 'tok', episodeId),
'http://10.0.0.2:8080/j/default/tok/$episodeId');
});
});
group('wire media', () {
test('a pick goes up as a ref without URL or token', () async {
final (accounts, _) = await linkedAccounts();
const pick = JellyfinMedia(
serverId: fixtureServerId,
baseUrl: 'https://example.invalid',
itemId: episodeId,
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6',
runtime: Duration(minutes: 23));
final media = putUpJellyfinMedia(accounts, pick);
expect(media.url, isNull);
expect(media.jellyfin!.itemId, episodeId);
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E6');
expect(media.jellyfin!.runtimeSecs, 23 * 60);
expect(jsonEncode(SwapIntent(media: media).toJson()), isNot(contains('TEST_ACCESS_TOKEN')));
expect(() => putUpJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
throwsFormatException);
accounts.markExpired(fixtureServerId);
expect(() => putUpJellyfinMedia(accounts, pick), throwsFormatException);
});
test('a pasted Jellyfin link becomes a ref (ServerId from /System/Info/Public, no api_key)',
() async {
final (accounts, fake) = await linkedAccounts();
final media = await mediaForLink(accounts,
'https://example.invalid/Videos/$episodeId/stream?static=true&api_key=SOMEONES_TOKEN');
expect(media.url, isNull);
expect(media.jellyfin!.serverId, fixtureServerId);
expect(media.jellyfin!.itemId, episodeId);
expect(media.jellyfin!.title, 'Rice and Thieves and Heroes', reason: 'title via own account');
expect(media.name, 'Rice and Thieves and Heroes');
expect(jsonEncode(media.toJson()), isNot(contains('SOMEONES_TOKEN')));
expect(fake.count('GET', '/System/Info/Public'), 1);
// Never sent with the pasted token.
expect(fake.requests.every((r) => !'${r.uri}${r.headers}'.contains('SOMEONES_TOKEN')), isTrue);
// A web UI link carries the ServerId: no lookup.
final web = await mediaForLink(accounts,
'https://example.invalid/web/#/details?id=$episodeId&serverId=$fixtureServerId');
expect(web.jellyfin!.serverId, fixtureServerId);
expect(fake.count('GET', '/System/Info/Public'), 1);
});
test('other links, or an unreachable Jellyfin, stay plain links', () async {
final fake = FakeJellyfin()..offline = true;
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => fake);
const url = 'https://jelly.example/Items/$episodeId/Download?api_key=x';
final media = await mediaForLink(accounts, url);
expect(media.jellyfin, isNull);
expect(media.url, url);
expect(media.name, 'Download');
final plain = await mediaForLink(accounts, 'https://cdn.example/movie.mkv');
expect(plain.url, 'https://cdn.example/movie.mkv');
expect(plain.name, 'movie.mkv');
});
test('relay credential: the account token with exactly its own device fields', () async {
final (accounts, _) = await linkedAccounts();
final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!;
final info = accounts.clientInfo;
expect(cred.token, 'TEST_ACCESS_TOKEN');
expect(cred.serverId, fixtureServerId);
expect(cred.baseUrl, 'https://example.invalid');
expect([cred.client, cred.device, cred.deviceId, cred.version],
[info.client, info.device, info.deviceId, info.version]);
expect(cred.device, 'Test PC');
// Another server's item: the default account still relays its own.
expect(relayCredentialFor(accounts, serverId: 'other')!.serverId, fixtureServerId);
accounts.markExpired(fixtureServerId);
expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull);
final none = JellyfinAccounts(store: MemoryJellyfinStore());
await none.ensureLoaded(validate: false);
expect(relayCredentialFor(none), isNull);
});
});
group('progress', () {
late JellyfinAccounts accounts;
late DateTime now;
late JellyfinProgressReporter reporter;
final errors = <Object>[];
var unauthorized = 0;
setUp(() async {
(accounts, _) = await linkedAccounts(http: playbackFake());
now = DateTime(2026, 1, 1, 20);
errors.clear();
unauthorized = 0;
reporter = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
clock: () => now,
onError: errors.add,
onUnauthorized: () => unauthorized++,
);
});
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
.then((_) {});
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
at(1, 0); // resume -> Progress
at(2, 1);
at(5, 4); // nothing due
at(11, 10); // 10 s since the last one
at(12, 11, paused: true); // pause
at(13, 11, paused: true);
at(14, 300, paused: true); // seek
at(15, 300, paused: true, sid: null); // subtitles off
await reporter.stop(const PlayerSnapshot(position: 300.5, paused: true, aid: 2));
expect(reports.map((r) => r.path.split('/').last), [
'Playing',
'Progress', // resumed
'Progress', // 10 s
'Progress', // paused
'Progress', // seek
'Progress', // track change
'Stopped',
]);
final playing = reports.first.body;
expect(playing['ItemId'], episodeId);
expect(playing['MediaSourceId'], episodeId);
expect(playing['PlaySessionId'], playSessionId);
expect(playing['PlayMethod'], 'DirectPlay');
expect(playing['IsPaused'], isTrue);
expect(playing['AudioStreamIndex'], 2);
expect(playing['SubtitleStreamIndex'], 3);
expect(reports[2].body['PositionTicks'], 10 * jellyfinTicksPerSecond);
expect(reports[3].body['IsPaused'], isTrue);
expect(reports[4].body['PositionTicks'], 300 * jellyfinTicksPerSecond);
expect(reports[5].body['SubtitleStreamIndex'], -1);
expect(reports.last.body, {
'ItemId': episodeId,
'MediaSourceId': episodeId,
'PlaySessionId': playSessionId,
'PositionTicks': 0,
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
expect(errors, isEmpty);
});
test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
['Stopped', 'UserPlayedItems', 'UserData']);
expect(reports[reports.length - 3].body.containsKey('PositionTicks'), isFalse);
expect(reports.last.body, {'PlaybackPositionTicks': 0}, reason: 'the resume point Progress left is cleared');
final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body]
.where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond);
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
});
test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
playedBefore: true,
clock: () => now,
);
r.start(const PlayerSnapshot(position: 700, paused: false));
now = now.add(const Duration(seconds: 1));
r.update(const PlayerSnapshot(position: 701, paused: true));
await r.stop(const PlayerSnapshot(position: 701, paused: true));
expect(reports.map((r) => r.path.split('/').last),
['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems', 'UserData']);
expect(reports[2].body.containsKey('PositionTicks'), isFalse);
expect(reports[3].body.containsKey('PositionTicks'), isFalse);
});
test('played to the end, then seeked back: a fresh Playing', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));
expect(reporter.active, isFalse);
reporter.update(const PlayerSnapshot(position: 1440, paused: true));
reporter.start(const PlayerSnapshot(position: 600, paused: false));
await idle();
expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped', 'Playing', 'Stopped']);
});
test('failures are handed on and never throw; a 401 ends reporting', () async {
final fake = playbackFake()
..on('POST', '/Sessions/Playing/Progress', (_, _) => const JellyfinHttpResponse(500, ''));
(accounts, _) = await linkedAccounts(http: fake);
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
clock: () => now,
onError: errors.add,
onUnauthorized: () => unauthorized++,
);
r.start(const PlayerSnapshot(position: 0, paused: false));
r.update(const PlayerSnapshot(position: 0, paused: true));
await r.stop(const PlayerSnapshot(position: 1, paused: true));
expect(errors, hasLength(1));
expect(unauthorized, 0);
expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped']);
fake.on('POST', '/Sessions/Playing', (_, _) => const JellyfinHttpResponse(401, ''));
reports.clear();
r.start(const PlayerSnapshot(position: 0, paused: false));
r.update(const PlayerSnapshot(position: 0, paused: true));
await r.stop(const PlayerSnapshot(position: 1, paused: true));
expect(unauthorized, 1);
expect(reports, isEmpty, reason: 'nothing after the 401');
expect(r.active, isFalse);
});
});
group('episodes', () {
test('neighbours of an episode with the clicker\'s account', () async {
final (accounts, _) = await linkedAccounts(http: playbackFake());
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null)
as OwnPlay;
final around = await findAdjacentEpisodes(plan);
expect(around.previous, isNull, reason: 'S1E6 is first in the fixture');
expect(around.next!.item.id, nextEpisodeId);
expect(around.next!.label, 'S1E7 Battle with the Heroes');
final media = mediaForPick(around.next!.pick);
expect(media.jellyfin!.itemId, nextEpisodeId);
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
});
test('one auto-advance swapper: the lowest id among members with their own account', () {
final members = [
member('m10', 'Ann', jf: {fixtureServerId: true}),
member('m2', 'Bob', jf: {fixtureServerId: true}),
member('m1', 'Guest', jf: {fixtureServerId: false}),
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
member('m3', 'Fresh'),
];
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
reason: 'order does not matter: every client agrees');
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
expect(compareMemberIds('m9', 'm10'), lessThan(0));
});
test('member route: via server only for relay viewers of the current item', () {
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
expect(route(member('m1', 'G', jf: {fixtureServerId: false})), DeliveryRoute.server);
expect(route(member('m1', 'G', jf: {fixtureServerId: false}), relay: false), isNull);
expect(route(member('m1', 'G', jf: {fixtureServerId: true})), DeliveryRoute.direct);
expect(route(member('m1', 'G')), isNull);
});
});
group('track carry-over', () {
test('choice from Jellyfin streams carries to the next episode by traits', () {
final source = fixtureSource();
// mpv aid 2 / sid 2: English eac3 audio, "English (Signs)" subtitles.
final choice = TrackChoice.of(aid: 2, sid: 2, source: source);
expect(choice.audio!.language, 'eng');
expect(choice.subtitle!.title, 'English (Signs) [Generated]');
// Next file: same tracks in another order (English audio first).
final next = JellyfinMediaSource(id: 'n', mediaStreams: [
const JellyfinMediaStream(index: 0, type: JellyfinStreamType.video),
const JellyfinMediaStream(
index: 1, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'eac3', channels: 2),
const JellyfinMediaStream(
index: 2, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'eac3', channels: 2),
const JellyfinMediaStream(
index: 3, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Full Subtitle)'),
const JellyfinMediaStream(
index: 4, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Signs)'),
]);
final pick = chooseTracks(choice, source: next);
expect(pick.aid, 1, reason: 'English is mpv aid 1 there');
expect(pick.sid, 2, reason: 'Signs, not Full');
expect(pick.subtitlesOff, isFalse);
final off = chooseTracks(TrackChoice.of(aid: 1, sid: null, source: source), source: next);
expect(off.aid, 2, reason: 'Japanese');
expect(off.subtitlesOff, isTrue);
});
test('relay viewers match on mpv\'s track list', () {
const tracks = [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'aac'),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'aac'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Signs'),
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Full'),
];
final choice = TrackChoice.of(aid: 2, sid: 2, tracks: tracks);
expect(choice.audio!.title, 'English');
final pick = chooseTracks(choice, tracks: const [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'en', title: 'English'),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'ja', title: 'Japanese'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'en', title: 'Full Subtitles'),
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'en', title: 'Signs & Songs'),
]);
expect(pick.aid, 1);
expect(pick.sid, 1);
});
test('kept per series with a global fallback', () async {
final prefs = TrackPrefs(store: MemoryJellyfinStore());
expect(await prefs.load(seriesId: 'a'), isNull);
final eng = TrackChoice.of(aid: 2, sid: null, source: fixtureSource());
await prefs.save(eng, seriesId: 'a');
final jpn = TrackChoice.of(aid: 1, sid: 1, source: fixtureSource());
await prefs.save(jpn, seriesId: 'b');
expect((await prefs.load(seriesId: 'a'))!.audio!.language, 'eng');
expect((await prefs.load(seriesId: 'a'))!.subtitlesOff, isTrue);
expect((await prefs.load(seriesId: 'b'))!.audio!.language, 'jpn');
expect((await prefs.load(seriesId: 'new'))!.audio!.language, 'jpn', reason: 'global = last');
expect((await prefs.load())!.subtitle!.title, 'English (Full Subtitle)');
});
});
group('browser location', () {
test('route stacks round-trip and survive in the Jellyfin box', () async {
final (accounts, _) = await linkedAccounts();
final stack = [
const HomeRoute(),
const LibraryRoute(
JellyfinItem(id: 'lib1', name: 'Shows', type: 'CollectionFolder', collectionType: 'tvshows')),
SeriesRoute(const JellyfinItem(id: seriesId, name: 'Black Summoner', type: 'Series'),
seasonId: seasonId),
];
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack(stack));
final back = decodeBrowseStack(accounts.browseLocation(fixtureServerId))!;
expect(back.map((r) => r.key), stack.map((r) => r.key));
expect((back[1] as LibraryRoute).folder.collectionType, 'tvshows');
expect((back[2] as SeriesRoute).seasonId, seasonId);
expect(decodeBrowseStack('junk'), isNull);
});
testWidgets('the browser reopens where it was, per server', (tester) async {
await setSize(tester, const Size(1600, 1000));
final (accounts, _) = await linkedAccounts();
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([
const HomeRoute(),
SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'),
seasonId: season2Id),
]));
await tester.pumpWidget(scoped(_graph, app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}))));
await settle(tester);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue);
// Back to Home is remembered too.
await tester.tap(find.byTooltip('Back (Esc)'));
await settle(tester);
expect(decodeBrowseStack(accounts.browseLocation(fixtureServerId))!.map((r) => r.key), ['home']);
});
});
group('session widgets', () {
final next = EpisodeLink(
const JellyfinItem(
id: nextEpisodeId, name: 'Battle with the Heroes', type: 'Episode',
parentIndexNumber: 1, indexNumber: 7),
const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
var play = 0, cancel = 0;
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
await tester.tap(find.byKey(const ValueKey('play-next')));
expect(play, 1);
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
next: next,
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
onPlayNow: () => play++,
onCancel: () => cancel++))));
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
expect(cancel, 1);
});
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
var pressed = 0;
await tester.pumpWidget(scoped(_graph, app(Align(child: NextEpisodePill(episode: next, onPressed: () => pressed++)))));
double opacity() => tester.widget<AnimatedOpacity>(find.descendant(
of: find.byType(NextEpisodePill), matching: find.byType(AnimatedOpacity))).opacity;
expect(opacity(), lessThan(0.5));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('next-episode-pill'))));
await tester.pumpAndSettle();
expect(opacity(), 1);
await tester.tap(find.byKey(const ValueKey('next-episode-pill')));
expect(pressed, 1);
});
testWidgets('blocked card offers to link an account', (tester) async {
var link = 0;
await tester.pumpWidget(scoped(_graph, app(JellyfinBlockedCard(
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
onLinkAccount: () => link++,
onRetry: () {}))));
expect(find.text('No Jellyfin account here for Example Server, and nobody is passing it on'),
findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
expect(link, 1);
});
});
}