import 'dart:convert'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts_panel.dart'; import 'package:watch_together/src/jellyfin/browser.dart'; import 'package:watch_together/src/jellyfin/browse_scope.dart'; import 'package:watch_together/src/jellyfin/picker_source.dart'; import 'package:watch_together/src/state/settings.dart'; import 'jellyfin_accounts_test.dart' show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id; Future setSize(WidgetTester tester, Size size) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); } Widget app(Widget child) => MaterialApp( theme: ThemeData.dark(), home: Scaffold(body: Padding(padding: const EdgeInsets.all(16), child: child)), ); /// Lets fake requests, debounces and image errors settle without waiting /// on the progress spinners (pumpAndSettle would never return). Future settle(WidgetTester tester, [int frames = 6]) async { for (var i = 0; i < frames; i++) { await tester.pump(const Duration(milliseconds: 50)); } } /// Pinned "now" for the Continue row (the fixtures were captured in /// October 2026). final fixtureNow = DateTime.utc(2026, 10, 8, 12); /// What the browser reported: selections and put-ups. class Picks { final selected = []; final putUp = []; } /// The browser on one linked fake server; clicks select, double-clicks put /// up (both recorded), like the Library tab wires it. Future<(Picks, JellyfinAccounts, FakeJellyfin, BrowseController)> pumpBrowser( WidgetTester tester, Size size) async { await setSize(tester, size); // Memory store + fake transport: no real IO, so no runAsync (futures // made there would resume outside the test's fake clock). final (accounts, fake) = await linkedAccounts(); final controller = BrowseController(accounts, now: () => fixtureNow); addTearDown(controller.dispose); final picks = Picks(); await tester.pumpWidget(app(StatefulBuilder( builder: (context, setState) => JellyfinBrowser( accounts: accounts, controller: controller, selected: picks.selected.lastOrNull, onSelect: (s) => setState(() => picks.selected.add(s)), onPutUp: picks.putUp.add, ), ))); await settle(tester); return (picks, accounts, fake, controller); } Future openArifureta(WidgetTester tester) async { await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta'); await settle(tester, 10); expect(find.byKey(const ValueKey('search-results')), findsOneWidget); // Series cards select; the series page opens from the details panel or // a season card, here straight through the controller. await tester.tap(find.byKey(const ValueKey('card-$arifuretaId'))); await settle(tester); } void main() { testWidgets('home: one Continue row (resume + next up per series), latest as series cards', (tester) async { final (_, _, fake, controller) = await pumpBrowser(tester, const Size(1600, 2400)); expect(find.text('Continue'), findsOneWidget); expect(find.text('Continue watching'), findsNothing); expect(find.text('Next up'), findsNothing); expect(find.text('Latest in Shows'), findsOneWidget); expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget); final row = find.byKey(const ValueKey('row-continue')); // JoJo and Black Summoner were watched yesterday; Black Summoner's // resume point is older than its last finished episode, so its next // up shows. Bumpkin (62 days) and the never-dated series drop out. expect(find.descendant(of: row, matching: find.text("JoJo's Bizarre Adventure")), findsOneWidget); expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsOneWidget); expect(find.descendant(of: row, matching: find.textContaining('S1E6')), findsOneWidget); expect(find.descendant(of: row, matching: find.text('From Old Country Bumpkin to Master Swordsman')), findsNothing); final jojo = tester.getTopLeft(find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8'))); final bs = tester.getTopLeft(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052'))); expect(jojo.dx, lessThan(bs.dx)); expect(controller.continueKeys, {'5055eea092a576ea09eee3b6527ba857', 'e9aec9c14a1fcab775111844112b4cf7'}); // Latest asks for single items and groups them itself; grouped series // from the server say how many are new. final latest = fake.requests.lastWhere((r) => r.uri.path == '/Items/Latest').uri.queryParameters; expect(latest['groupItems'], 'false'); expect(find.text('30 new'), findsWidgets); expect(tester.takeException(), isNull); }); testWidgets('cards: click selects, double-click puts up; Show all opens newest first', (tester) async { final (picks, _, fake, controller) = await pumpBrowser(tester, const Size(1600, 1200)); final card = find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')); await tester.tap(card); await tester.pump(); expect(picks.selected.single.itemId, 'b18826be994070a79f9b52bc0f66f052'); expect(picks.putUp, isEmpty); // Selected: outline and check. expect(find.descendant(of: card, matching: find.byIcon(Icons.check)), findsOneWidget); await tester.tap(card); await tester.pump(const Duration(milliseconds: 60)); await tester.tap(card); await tester.pump(); expect(picks.putUp.single.itemId, 'b18826be994070a79f9b52bc0f66f052'); await tester.tap(find.byKey(const ValueKey('show-all-a656b907eb3a73532e40e44b968d0225'))); await settle(tester); final q = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters; expect(q['sortBy'], startsWith('DateCreated')); expect(q['sortOrder'], 'Descending'); expect(find.widgetWithText(OutlinedButton, 'Date added'), findsOneWidget); expect((controller.stack.last as LibraryRoute).sort, 'added'); expect(tester.takeException(), isNull); }); testWidgets('Latest: "Hide re-releases" hides items added long after airing', (tester) async { final fake = FakeJellyfin(); fake.on('GET', '/Items/Latest', (_, _) => JellyfinHttpResponse(200, jsonEncode([ { 'Id': 'fresh', 'Name': 'Fresh', 'Type': 'Episode', 'SeriesId': 'sf', 'SeriesName': 'Fresh Show', 'ParentIndexNumber': 1, 'IndexNumber': 1, 'PremiereDate': '2026-10-01T00:00:00Z', 'DateCreated': '2026-10-02T00:00:00Z', }, { 'Id': 'old', 'Name': 'Old', 'Type': 'Episode', 'SeriesId': 'so', 'SeriesName': 'Old Show', 'ParentIndexNumber': 1, 'IndexNumber': 4, 'PremiereDate': '2012-10-01T00:00:00Z', 'DateCreated': '2026-10-02T00:00:00Z', }, ]))); await setSize(tester, const Size(1600, 1400)); final (accounts, _) = await linkedAccounts(http: fake); final controller = BrowseController(accounts, now: () => fixtureNow); addTearDown(controller.dispose); AppSettings.instance.hideReReleases.value = true; addTearDown(() => AppSettings.instance.hideReReleases.value = true); await tester.pumpWidget(app(JellyfinBrowser( accounts: accounts, controller: controller, onSelect: (_) {}, onPutUp: (_) {}))); await settle(tester); expect(find.text('Fresh Show'), findsWidgets); expect(find.text('S1E1'), findsWidgets); expect(find.text('Old Show'), findsNothing); await tester.tap(find.byKey(const ValueKey('latest-menu-a656b907eb3a73532e40e44b968d0225'))); await settle(tester, 4); await tester.tap(find.byKey(const ValueKey('hide-rereleases'))); await settle(tester, 4); expect(AppSettings.instance.hideReReleases.value, isFalse); expect(find.text('Old Show'), findsWidgets); expect(tester.takeException(), isNull); }); testWidgets('series page: smart next button and episodes select, double-click puts up', (tester) async { final (picks, _, _, controller) = await pumpBrowser(tester, const Size(1600, 1000)); await openArifureta(tester); expect(picks.selected.last.itemId, arifuretaId); controller.open(SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'))); await settle(tester); // Opens on the season holding next up (S2E4). final s2 = tester.widget(find.byKey(const ValueKey('season-$season2Id'))); expect(s2.selected, isTrue); expect(find.text('1. Irregular'), findsOneWidget); expect(find.textContaining('Next up: S2E4'), findsOneWidget); await tester.tap(find.widgetWithText(ChoiceChip, 'Season 3')); await settle(tester); expect(find.text('1. Irregular'), findsNothing); expect(find.text('1. Haulias Assemble'), findsWidgets); await tester.tap(find.text('2. Roar of Revolution').first); await tester.pump(); final sel = picks.selected.last; expect(sel.serverId, fixtureServerId); expect(sel.item!.name, 'Roar of Revolution'); expect(picks.putUp, isEmpty); await tester.tap(find.text('2. Roar of Revolution').first); await tester.pump(const Duration(milliseconds: 60)); await tester.tap(find.text('2. Roar of Revolution').first); await tester.pump(); expect(picks.putUp.single.item!.name, 'Roar of Revolution'); // The smart button selects next up. await tester.tap(find.byKey(const ValueKey('series-next'))); await tester.pump(); expect(picks.selected.last.item!.episodeLabel, 'S2E4'); expect(tester.takeException(), isNull); }); testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async { final (_, _, _, controller) = await pumpBrowser(tester, const Size(1600, 1000)); final search = find.byKey(const ValueKey('jf-search')); // Focus a card, then arrow right moves to the next card. final first = find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8')); Focus.of(tester.element(find.descendant(of: first, matching: find.byType(Column)).first)) .requestFocus(); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType()!; expect((ink.key as ValueKey).value, 'card-b18826be994070a79f9b52bc0f66f052'); // "/" jumps to search. await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/'); await tester.pump(); expect(tester.widget(search).focusNode!.hasFocus, isTrue); await tester.enterText(search, 'arifureta'); await settle(tester, 10); expect(find.byKey(const ValueKey('search-results')), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump(); expect(find.byKey(const ValueKey('search-results')), findsNothing); expect(find.text('Continue'), findsOneWidget); // Into a series and back with Esc. controller.open(SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'))); await settle(tester); expect(find.byKey(const ValueKey('series-next')), findsOneWidget); expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump(); expect(find.byKey(const ValueKey('series-next')), findsNothing); expect(find.text('Continue'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('library grid with sort and filters', (tester) async { final (_, _, fake, _) = await pumpBrowser(tester, const Size(1600, 1000)); await tester.tap(find.widgetWithText(ActionChip, 'Shows')); await settle(tester); expect(find.text('218 items'), findsOneWidget); expect(find.text('Alma-chan Wants to Be a Family!'), findsWidgets); await tester.tap(find.byKey(const ValueKey('filter-unplayed'))); await settle(tester); final q = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters; expect(q['filters'], 'IsUnplayed'); expect(q['includeItemTypes'], 'Series'); expect(q['recursive'], 'true'); await tester.tap(find.byKey(const ValueKey('library-sort'))); await settle(tester, 2); await tester.tap(find.byKey(const ValueKey('sort-added'))); await settle(tester); final q2 = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters; expect(q2['sortBy'], startsWith('DateCreated')); expect(q2['sortOrder'], 'Descending'); expect(tester.takeException(), isNull); }); testWidgets('layouts: narrow and ultrawide fill rows with whole cards; rows scroll by drag', (tester) async { await pumpBrowser(tester, const Size(820, 700)); expect(tester.takeException(), isNull); final narrowCard = tester.getSize(find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8'))); await pumpBrowser(tester, const Size(3440, 1440)); expect(tester.takeException(), isNull); final wideCards = find.byWidgetPredicate((w) => w is InkWell && w.key is ValueKey && (w.key as ValueKey).value.startsWith('card-')); expect(wideCards.evaluate().length, greaterThan(10)); expect(narrowCard.width, greaterThan(200)); // A Latest row is an HScroll: a mouse drag moves it sideways. await setSize(tester, const Size(640, 1400)); await settle(tester); final row = find.byKey(const ValueKey('latest-ee75511ae395034b1e7e657707b15125')); final list = find.descendant(of: row, matching: find.byType(Scrollable)).first; final before = tester.state(list).position.pixels; await tester.drag(list, const Offset(-300, 0), kind: PointerDeviceKind.mouse); await settle(tester, 4); expect(tester.state(list).position.pixels, greaterThan(before)); expect(tester.takeException(), isNull); }); testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops', (tester) async { await setSize(tester, const Size(1200, 900)); final fake = FakeJellyfin(); final accounts = JellyfinAccounts( store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test'); await accounts.ensureLoaded(validate: false); await tester.pumpWidget(app(SingleChildScrollView( child: JellyfinAccountsPanel( accounts: accounts, pollInterval: const Duration(milliseconds: 100))))); await tester.tap(find.byKey(const ValueKey('jf-add-server'))); await tester.pump(); await tester.enterText(find.byKey(const ValueKey('jf-address')), 'example.invalid'); await tester.tap(find.byKey(const ValueKey('jf-connect'))); await settle(tester, 2); expect(find.text('Example Server'), findsOneWidget); expect(find.textContaining('Jellyfin 10.11.11'), findsOneWidget); // Cancel: the code goes away and polling stops. await tester.tap(find.byKey(const ValueKey('jf-quick-connect'))); await settle(tester, 2); expect(find.text('437392'), findsOneWidget); expect(find.textContaining('Profile → Quick Connect'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('jf-qc-cancel'))); await settle(tester, 4); final polls = fake.count('GET', '/QuickConnect/Connect'); await settle(tester, 6); expect(fake.count('GET', '/QuickConnect/Connect'), polls); expect(find.text('437392'), findsNothing); // Again, and approve it. await tester.tap(find.byKey(const ValueKey('jf-quick-connect'))); await settle(tester, 2); expect(find.text('437392'), findsOneWidget); fake.approved = true; await settle(tester, 6); expect(accounts.accounts.single.userName, 'testuser'); expect(find.byKey(const ValueKey('jf-account-$fixtureServerId')), findsOneWidget); expect(find.text('testuser on Example Server'), findsOneWidget); expect(find.byKey(const ValueKey('jf-sign-in')), findsNothing); // Expired token: Reconnect shows, the account stays. accounts.markExpired(fixtureServerId); await tester.pump(); expect(find.byKey(const ValueKey('jf-reconnect-$fixtureServerId')), findsOneWidget); // Sign out asks, revokes, unlinks. await tester.tap(find.byKey(const ValueKey('jf-sign-out-$fixtureServerId'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('jf-sign-out-confirm'))); await settle(tester, 4); expect(fake.count('POST', '/Sessions/Logout'), 1); expect(accounts.isEmpty, isTrue); expect(tester.takeException(), isNull); }); testWidgets('no linked server: the Jellyfin source offers sign-in inline', (tester) async { await setSize(tester, const Size(1200, 900)); final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin()); await accounts.ensureLoaded(validate: false); await tester.pumpWidget(app(JellyfinPickerSource( accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}, onOpenIntegrations: () {}))); await tester.pump(); expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget); expect(find.text('Open Integrations'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('expired account shows a reconnect banner in the browser', (tester) async { final (_, accounts, _, _) = await pumpBrowser(tester, const Size(1400, 900)); accounts.markExpired(fixtureServerId); await tester.pump(); expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget); expect(find.text('Reconnect'), findsOneWidget); expect(find.byType(JellyfinBrowser), findsOneWidget); }); }