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>
This commit is contained in:
1 parent
99cda7ed04
commit
d092ea371a
54 files changed
+4963
-1825
No files matched your search
@@ -206,4 +206,32 @@ void main() {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'feed and chat toasts name their actor exactly',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
final friend = SyncClient();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: server.boundPort, secret: 'toast', name: 'Bob'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr');
|
||||
friend.sendChat('hi there');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast');
|
||||
final toasts = coord.toasts.peek();
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/files/ui/files_page.dart';
|
||||
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/screens/home/health_probe.dart';
|
||||
@@ -82,7 +83,7 @@ void main() {
|
||||
|
||||
test('pasting sorts links and paths into the right source', () {
|
||||
final c = MediaPickerController();
|
||||
expect(c.source, MediaKind.file);
|
||||
expect(c.source, MediaKind.jellyfin);
|
||||
c.pasteText(' https://example.com/a.mkv ');
|
||||
expect(c.source, MediaKind.link);
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
|
||||
@@ -129,13 +130,13 @@ void main() {
|
||||
|
||||
testWidgets('tabs and settings pages switch the body', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('library-tab')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-schedule')));
|
||||
// The schedule shows a spinner while it loads: pump frames, not settle.
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
expect(find.byKey(const ValueKey('swap-panel')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('library-tab')), findsNothing);
|
||||
expect(find.byType(SchedulePage), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-settings')));
|
||||
for (var i = 0; i < 30; i++) {
|
||||
@@ -148,7 +149,7 @@ void main() {
|
||||
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('settings-updates')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Check automatically on start'), findsOneWidget);
|
||||
expect(find.text('Check for updates on start'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('settings-shortcuts')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Toggle ready'), findsOneWidget);
|
||||
@@ -160,7 +161,7 @@ void main() {
|
||||
expect(find.text('Dev build — update checks are off'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-library')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('library-tab')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -229,21 +230,20 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('library: picker segments switch the input; Integrations opens Settings', (tester) async {
|
||||
testWidgets('library: sources switch; offline nothing goes up; Integrations opens Settings', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
await tester.tap(find.text('File'));
|
||||
await tester.tap(find.text('Files'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('media-file')), findsOneWidget);
|
||||
expect(find.text('Browse'), findsOneWidget);
|
||||
expect(find.byType(FilesPage), findsOneWidget);
|
||||
await tester.tap(find.text('Link'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('media-link')), findsOneWidget);
|
||||
expect(find.text('Paste'), findsOneWidget);
|
||||
expect(find.text('Watch from a link'), findsOneWidget);
|
||||
// Offline nothing goes up.
|
||||
expect(find.text('Join or host to watch together'), findsOneWidget);
|
||||
expect(find.text('How the others get it'), findsNothing);
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await tester.pumpAndSettle();
|
||||
// No linked server: sign-in right in the picker.
|
||||
// No linked server: sign-in right in the library.
|
||||
expect(find.text('Add a Jellyfin server'), findsOneWidget);
|
||||
await tester.tap(find.text('Open Integrations'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -269,14 +269,18 @@ void main() {
|
||||
|
||||
testWidgets('narrow window: strip, cards and library fit', (tester) async {
|
||||
await pumpShell(tester, const Size(480, 800));
|
||||
expect(tester.takeException(), isNull);
|
||||
final ex = tester.takeException();
|
||||
expect(ex, isNull);
|
||||
await openCard(tester, 'strip-join');
|
||||
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tapAt(const Offset(5, 790));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
// Narrow: the sources are icons with tooltips.
|
||||
for (final source in ['Link', 'Files', 'Jellyfin']) {
|
||||
await tester.tap(find.byTooltip(source));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull, reason: source);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,20 @@ class FakeJellyfin implements JellyfinHttp {
|
||||
bool offline = false;
|
||||
int meStatus = 200;
|
||||
|
||||
/// Finished episodes (`/Items?filters=IsPlayed&sortBy=DatePlayed`): JoJo
|
||||
/// and Black Summoner watched on 2026-10-07, after Black Summoner's
|
||||
/// resume point (10-06), so its next-up episode is what continues.
|
||||
List<Object> recentlyPlayed = [
|
||||
{
|
||||
'Id': 'jojo-played', 'Name': 'Played', 'Type': 'Episode', 'SeriesId': '5055eea092a576ea09eee3b6527ba857',
|
||||
'UserData': {'Played': true, 'LastPlayedDate': '2026-10-07T21:00:00Z'},
|
||||
},
|
||||
{
|
||||
'Id': 'bs-played', 'Name': 'Played', 'Type': 'Episode', 'SeriesId': 'e9aec9c14a1fcab775111844112b4cf7',
|
||||
'UserData': {'Played': true, 'LastPlayedDate': '2026-10-07T20:00:00Z'},
|
||||
},
|
||||
];
|
||||
|
||||
FakeJellyfin() {
|
||||
fixture('GET', '/System/Info/Public', 'pub');
|
||||
on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true'));
|
||||
@@ -58,6 +72,7 @@ class FakeJellyfin implements JellyfinHttp {
|
||||
fixture('GET', '/Items/Latest', 'latest_shows');
|
||||
on('GET', '/Items', (uri, _) {
|
||||
final q = uri.queryParameters;
|
||||
if (q['filters'] == 'IsPlayed' && q['sortBy'] == 'DatePlayed') return _page(recentlyPlayed);
|
||||
if ((q['searchTerm'] ?? '').isNotEmpty) {
|
||||
return q['searchTerm']!.toLowerCase().startsWith('ari')
|
||||
? JellyfinHttpResponse(200, jellyfinFixture('search'))
|
||||
@@ -307,10 +322,12 @@ void main() {
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('a saved Jellyfin pick starts on the Jellyfin source', () {
|
||||
test('the library starts on Jellyfin unless told otherwise; a saved pick is selected', () {
|
||||
const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't');
|
||||
expect(MediaPickerController(jellyfin: m).source, MediaKind.jellyfin);
|
||||
expect(MediaPickerController(jellyfin: m, mediaPath: '/x.mkv').source, MediaKind.file);
|
||||
expect(MediaPickerController(jellyfin: m).selected!.itemId, 'i');
|
||||
expect(MediaPickerController(mediaPath: '/x.mkv', linkUrl: 'https://a/b').source, MediaKind.jellyfin);
|
||||
expect(MediaPickerController(source: MediaKind.link).source, MediaKind.link);
|
||||
});
|
||||
|
||||
test('JellyfinMedia round-trips and labels', () {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/shell/library_tab.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;
|
||||
@@ -30,49 +34,149 @@ Future<void> settle(WidgetTester tester, [int frames = 6]) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// MediaPicker on the Jellyfin source with one linked fake server.
|
||||
Future<(MediaPickerController, JellyfinAccounts, FakeJellyfin)> pumpPicker(
|
||||
/// 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 = <JellyfinSelection>[];
|
||||
final putUp = <JellyfinSelection>[];
|
||||
}
|
||||
|
||||
/// 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 c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
|
||||
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 (c, accounts, fake);
|
||||
return (picks, accounts, fake, controller);
|
||||
}
|
||||
|
||||
Future<void> 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 rows: continue watching with time left, next up, latest', (tester) async {
|
||||
await pumpPicker(tester, const Size(1600, 2400));
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
expect(find.text('Next up'), findsOneWidget);
|
||||
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);
|
||||
// Continue watching: series name on top, "S1E10 · N min left · title" below.
|
||||
expect(find.text('From Old Country Bumpkin to Master Swordsman'), findsWidgets);
|
||||
expect(find.textContaining(RegExp(r'^S1E10 · \d+ min left')), findsOneWidget);
|
||||
expect(find.text('Black Summoner'), findsWidgets);
|
||||
expect(find.text('Monogatari'), findsWidgets);
|
||||
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('series page: smart next button, season switching, pick an episode',
|
||||
testWidgets('cards: click selects, double-click puts up; Show all opens newest first',
|
||||
(tester) async {
|
||||
final (c, _, _) = await pumpPicker(tester, const Size(1600, 1000));
|
||||
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).
|
||||
expect(find.byKey(const ValueKey('season-$season2Id')), findsOneWidget);
|
||||
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
|
||||
expect(s2.selected, isTrue);
|
||||
expect(find.text('1. Irregular'), findsOneWidget);
|
||||
@@ -83,37 +187,38 @@ void main() {
|
||||
expect(find.text('1. Irregular'), findsNothing);
|
||||
expect(find.text('1. Haulias Assemble'), findsWidgets);
|
||||
|
||||
// Picking an episode fills the controller.
|
||||
await tester.tap(find.text('2. Roar of Revolution').first);
|
||||
await tester.pump();
|
||||
final sel = c.selection;
|
||||
expect(sel, isA<JellyfinMedia>());
|
||||
sel as JellyfinMedia;
|
||||
final sel = picks.selected.last;
|
||||
expect(sel.serverId, fixtureServerId);
|
||||
expect(sel.title, 'Roar of Revolution');
|
||||
expect(sel.subtitle, "Arifureta: From Commonplace to World's Strongest · S3E2");
|
||||
expect(sel.baseUrl, 'https://example.invalid');
|
||||
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 picks next up.
|
||||
// The smart button selects next up.
|
||||
await tester.tap(find.byKey(const ValueKey('series-next')));
|
||||
await tester.pump();
|
||||
expect((c.selection as JellyfinMedia).subtitle, endsWith('S2E4'));
|
||||
expect(picks.selected.last.item!.episodeLabel, 'S2E4');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async {
|
||||
await pumpPicker(tester, const Size(1600, 1000));
|
||||
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-b79037c4a4c2707287dd4a29965220f3'));
|
||||
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<InkWell>()!;
|
||||
expect((ink.key as ValueKey<String>).value, 'card-43c0f58d4f0c13e4df01d737b695fae7');
|
||||
expect((ink.key as ValueKey<String>).value, 'card-b18826be994070a79f9b52bc0f66f052');
|
||||
|
||||
// "/" jumps to search.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/');
|
||||
@@ -125,21 +230,22 @@ void main() {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('search-results')), findsNothing);
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
|
||||
// Into the series and back with Esc.
|
||||
await openArifureta(tester);
|
||||
// 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 watching'), findsOneWidget);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('library grid with sort and filters', (tester) async {
|
||||
final (_, _, fake) = await pumpPicker(tester, const Size(1600, 1000));
|
||||
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);
|
||||
@@ -160,33 +266,29 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('layouts: narrow has no inspector, ultrawide adds it and fits more columns',
|
||||
testWidgets('layouts: narrow and ultrawide fill rows with whole cards; rows scroll by drag',
|
||||
(tester) async {
|
||||
await pumpPicker(tester, const Size(820, 700));
|
||||
expect(find.byKey(const ValueKey('jf-inspector')), findsNothing);
|
||||
await pumpBrowser(tester, const Size(820, 700));
|
||||
expect(tester.takeException(), isNull);
|
||||
final narrowCard = tester.getSize(find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3')));
|
||||
final narrowCard = tester.getSize(find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8')));
|
||||
|
||||
await pumpPicker(tester, const Size(3440, 1440));
|
||||
expect(find.byKey(const ValueKey('jf-inspector')), findsOneWidget);
|
||||
await pumpBrowser(tester, const Size(3440, 1440));
|
||||
expect(tester.takeException(), isNull);
|
||||
// Rows fill the width with whole cards.
|
||||
final wideCards = find.byWidgetPredicate((w) =>
|
||||
w is InkWell && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('card-'));
|
||||
expect(wideCards.evaluate().length, greaterThan(10));
|
||||
expect(narrowCard.width, greaterThan(200));
|
||||
|
||||
// Hover feeds the inspector.
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.addPointer(location: Offset.zero);
|
||||
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')).first));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('jf-inspector')),
|
||||
matching: find.textContaining('Rice and Thieves and Heroes')),
|
||||
findsOneWidget);
|
||||
// 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<ScrollableState>(list).position.pixels;
|
||||
await tester.drag(list, const Offset(-300, 0), kind: PointerDeviceKind.mouse);
|
||||
await settle(tester, 4);
|
||||
expect(tester.state<ScrollableState>(list).position.pixels, greaterThan(before));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops',
|
||||
@@ -245,14 +347,12 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('no linked server: the picker offers sign-in inline', (tester) async {
|
||||
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);
|
||||
final c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(SingleChildScrollView(
|
||||
child: MediaPicker(controller: c, jellyfin: accounts, onOpenIntegrations: () {}))));
|
||||
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);
|
||||
@@ -260,61 +360,11 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('expired account shows a reconnect banner in the browser', (tester) async {
|
||||
final (_, accounts, _) = await pumpPicker(tester, const Size(1400, 900));
|
||||
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);
|
||||
});
|
||||
|
||||
testWidgets('Library tab: the last pick comes back; offline it only browses',
|
||||
(tester) async {
|
||||
await setSize(tester, const Size(1800, 1000));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
const pick = JellyfinMedia(
|
||||
serverId: fixtureServerId,
|
||||
baseUrl: 'https://example.invalid',
|
||||
itemId: 'b18826be994070a79f9b52bc0f66f052',
|
||||
title: 'Rice and Thieves and Heroes',
|
||||
subtitle: 'Black Summoner · S1E6');
|
||||
await accounts.rememberPick(pick);
|
||||
final c = MediaPickerController();
|
||||
addTearDown(c.dispose);
|
||||
await restoreLastPick(c, accounts, () => true);
|
||||
final put = <PickedMedia>[];
|
||||
Future<void> pump({required bool online}) => tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: LibraryTab(
|
||||
controller: c,
|
||||
accounts: accounts,
|
||||
online: online,
|
||||
isHost: false,
|
||||
onPutUp: (m) async {
|
||||
put.add(m);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
)));
|
||||
await pump(online: false);
|
||||
await settle(tester);
|
||||
// The saved pick comes back on the Jellyfin source.
|
||||
expect(c.selection, pick);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
expect(find.text('Join or host to watch together'), findsOneWidget);
|
||||
final go = find.byKey(const ValueKey('swap-go'));
|
||||
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
|
||||
|
||||
await pump(online: true);
|
||||
await settle(tester);
|
||||
expect(find.text('Put up: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
|
||||
await tester.tap(go);
|
||||
await settle(tester);
|
||||
expect(put, [pick]);
|
||||
tester.view.physicalSize = const Size(800, 900);
|
||||
await settle(tester, 2);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -6,12 +6,12 @@ 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/browse_scope.dart';
|
||||
import 'package:watch_together/src/jellyfin/browser.dart';
|
||||
import 'package:watch_together/src/jellyfin/progress.dart';
|
||||
import 'package:watch_together/src/jellyfin/session_media.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
|
||||
import 'package:watch_together/src/screens/session/swap_panel.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
|
||||
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia;
|
||||
import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf;
|
||||
@@ -437,9 +437,7 @@ void main() {
|
||||
SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'),
|
||||
seasonId: season2Id),
|
||||
]));
|
||||
final c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
|
||||
await tester.pumpWidget(app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {})));
|
||||
await settle(tester);
|
||||
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
|
||||
expect(s2.selected, isTrue);
|
||||
@@ -516,80 +514,5 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
|
||||
expect(link, 1);
|
||||
});
|
||||
|
||||
testWidgets('swap panel: the shared picker, File for everyone, link relay choice', (tester) async {
|
||||
await setSize(tester, const Size(1400, 900));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
final c = MediaPickerController(source: MediaKind.link);
|
||||
addTearDown(c.dispose);
|
||||
final swapped = <PickedMedia>[];
|
||||
var closed = 0;
|
||||
await tester.pumpWidget(app(SwapPanel(
|
||||
controller: c,
|
||||
accounts: accounts,
|
||||
isHost: false,
|
||||
onSwap: (m) async {
|
||||
swapped.add(m);
|
||||
return null;
|
||||
},
|
||||
onDone: () => closed++,
|
||||
)));
|
||||
await settle(tester);
|
||||
// A guest's file goes through the server's relay.
|
||||
expect(find.text('File'), findsOneWidget);
|
||||
expect(find.text('Jellyfin'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Through me'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('swap-go')));
|
||||
await settle(tester);
|
||||
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv', relay: LinkRelay.me)]);
|
||||
expect(closed, 1);
|
||||
|
||||
// The Jellyfin source is the library browser; a pick goes up as is.
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await settle(tester);
|
||||
expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(app(SwapPanel(
|
||||
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x')));
|
||||
await settle(tester);
|
||||
expect(find.text('File'), findsOneWidget);
|
||||
await tester.tap(find.text('File'));
|
||||
await settle(tester);
|
||||
expect(find.text('Friends stream it from your computer.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('swap panel opens on the series of the episode playing, episode highlighted',
|
||||
(tester) async {
|
||||
await setSize(tester, const Size(1600, 1000));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
// Somewhere else remembered: the series start wins.
|
||||
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([const HomeRoute()]));
|
||||
final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List;
|
||||
final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map)
|
||||
.cast<String, dynamic>());
|
||||
final pick = pickForRef(accounts,
|
||||
JellyfinRef(serverId: fixtureServerId, itemId: ep.id, title: ep.name))!;
|
||||
final c = MediaPickerController(jellyfin: pick, source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(SwapPanel(
|
||||
controller: c,
|
||||
accounts: accounts,
|
||||
isHost: false,
|
||||
jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep),
|
||||
onSwap: (_) async => null,
|
||||
)));
|
||||
await settle(tester, 10);
|
||||
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected,
|
||||
isTrue);
|
||||
expect(find.text('Put up: ${pick.label}'), findsOneWidget);
|
||||
final row = tester.widget<Material>(find
|
||||
.ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material))
|
||||
.first);
|
||||
expect((row.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent),
|
||||
reason: 'the episode playing is highlighted');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/browse_scope.dart';
|
||||
import 'package:watch_together/src/jellyfin/details_panel.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';
|
||||
|
||||
import 'jellyfin_accounts_test.dart'
|
||||
show FakeJellyfin, arifuretaId, fixtureServerId, jellyfinFixture, linkedAccounts, season2Id;
|
||||
import 'jellyfin_browser_test.dart' show fixtureNow, settle;
|
||||
|
||||
const bsSeries = 'e9aec9c14a1fcab775111844112b4cf7';
|
||||
const bsEpisode = 'b18826be994070a79f9b52bc0f66f052';
|
||||
const bsSeason = 'fb0b77cfda15445f9319ed9d753dba23';
|
||||
const s2e2 = 'f5600f84e2c02c1f30442bf679dcbb1d';
|
||||
|
||||
JellyfinHttpResponse page(List<Object> items) =>
|
||||
JellyfinHttpResponse(200, jsonEncode({'Items': items, 'TotalRecordCount': items.length}));
|
||||
|
||||
/// The fake server, plus: Arifureta in "Latest" (2 new), Black Summoner's
|
||||
/// series, season and episode, and watched-state calls.
|
||||
FakeJellyfin libraryFake() {
|
||||
final fake = FakeJellyfin();
|
||||
final arifureta = jsonDecode(jellyfinFixture('series_detail')) as Map<String, dynamic>;
|
||||
// Added when it aired: not a re-release.
|
||||
fake.on('GET', '/Items/Latest', (_, _) => JellyfinHttpResponse(200, jsonEncode([
|
||||
{...arifureta, 'ChildCount': 2, 'DateCreated': arifureta['PremiereDate']},
|
||||
])));
|
||||
final episode = jsonDecode(jellyfinFixture('episode_detail')) as Map<String, dynamic>;
|
||||
fake.on('GET', '/Items/$bsSeries',
|
||||
(_, _) => JellyfinHttpResponse(200, jsonEncode({'Id': bsSeries, 'Name': 'Black Summoner', 'Type': 'Series'})));
|
||||
fake.on('GET', '/Shows/$bsSeries/Seasons', (_, _) => page([
|
||||
{'Id': bsSeason, 'Name': 'Season 1', 'Type': 'Season', 'IndexNumber': 1, 'SeriesId': bsSeries},
|
||||
]));
|
||||
fake.on('GET', '/Shows/$bsSeries/Episodes', (_, _) => page([episode]));
|
||||
// Arifureta's episodes load on their own too (a pick restored by id).
|
||||
for (final e in (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List) {
|
||||
fake.on('GET', '/Items/${e['Id']}', (_, _) => JellyfinHttpResponse(200, jsonEncode(e)));
|
||||
}
|
||||
const ud = '{"Played":true,"PlaybackPositionTicks":0,"PlayCount":1}';
|
||||
fake.on('POST', '/UserPlayedItems/$bsEpisode', (_, _) => const JellyfinHttpResponse(200, ud));
|
||||
return fake;
|
||||
}
|
||||
|
||||
class FilesStub extends StatelessWidget {
|
||||
final bool online, relayed;
|
||||
final Future<String?> Function(String path) onPlay;
|
||||
const FilesStub({super.key, required this.online, required this.relayed, required this.onPlay});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(children: [
|
||||
Text('files online=$online relayed=$relayed'),
|
||||
TextButton(
|
||||
key: const ValueKey('stub-play'),
|
||||
onPressed: online ? () => onPlay('/v/a.mkv') : null,
|
||||
child: const Text('play'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
class Harness {
|
||||
final MediaPickerController controller;
|
||||
final JellyfinAccounts accounts;
|
||||
final FakeJellyfin fake;
|
||||
final put = <PickedMedia>[];
|
||||
final recents = RecentLinks(read: () async => null, write: (_) async {});
|
||||
Harness(this.controller, this.accounts, this.fake);
|
||||
}
|
||||
|
||||
Future<Harness> pumpLibrary(
|
||||
WidgetTester tester,
|
||||
Size size, {
|
||||
bool online = true,
|
||||
bool isHost = false,
|
||||
MediaKind source = MediaKind.jellyfin,
|
||||
Harness? reuse,
|
||||
BrowseStart? start,
|
||||
JellyfinMedia? pick,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final Harness h;
|
||||
if (reuse != null) {
|
||||
h = reuse;
|
||||
} else {
|
||||
final (accounts, fake) = await linkedAccounts(http: libraryFake());
|
||||
final c = MediaPickerController(source: source, jellyfin: pick);
|
||||
if (pick != null) c.source = source;
|
||||
addTearDown(c.dispose);
|
||||
h = Harness(c, accounts, fake);
|
||||
}
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: LibraryTab(
|
||||
// A new tab each time, like the shell after "Change video".
|
||||
key: UniqueKey(),
|
||||
controller: h.controller,
|
||||
accounts: h.accounts,
|
||||
online: online,
|
||||
isHost: isHost,
|
||||
jellyfinStart: start,
|
||||
recentLinks: h.recents,
|
||||
now: () => fixtureNow,
|
||||
oembed: (uri) async => jsonEncode({
|
||||
'title': 'A video',
|
||||
'author_name': 'Chan',
|
||||
}),
|
||||
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
|
||||
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
|
||||
onPutUp: (m) async {
|
||||
h.put.add(m);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
));
|
||||
await settle(tester, 10);
|
||||
return h;
|
||||
}
|
||||
|
||||
Finder arifuretaCard() => find.byKey(const ValueKey('card-$arifuretaId')).first;
|
||||
|
||||
Future<void> doubleTap(WidgetTester tester, Finder f) async {
|
||||
await tester.tap(f);
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
await tester.tap(f);
|
||||
await settle(tester, 10);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('source switch fades through; library chips sit in the header', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1600, 1000));
|
||||
expect(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225')), findsOneWidget);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Link'));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
// Fade through: the new view waits until the old one is gone.
|
||||
final incoming = tester.widget<FadeTransition>(
|
||||
find.ancestor(of: find.text('Watch from a link'), matching: find.byType(FadeTransition)).first);
|
||||
expect(incoming.opacity.value, 0);
|
||||
await settle(tester, 10);
|
||||
expect(find.text('Continue'), findsNothing);
|
||||
expect(find.text('Watch from a link'), findsOneWidget);
|
||||
expect(h.controller.source, MediaKind.link);
|
||||
|
||||
await tester.tap(find.text('Files'));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('files online=true relayed=true'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('stub-play')));
|
||||
await settle(tester);
|
||||
expect(h.put, [const FileMedia('/v/a.mkv')]);
|
||||
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await settle(tester, 10);
|
||||
// A chip opens its library; again goes back home.
|
||||
await tester.tap(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225')));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('218 items'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225')));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('select a card: the panel slides in; episodes pick what goes up', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1600, 1000));
|
||||
expect(find.byKey(const ValueKey('jf-details')), findsNothing);
|
||||
await tester.tap(arifuretaCard());
|
||||
await settle(tester, 10);
|
||||
final panel = find.byKey(const ValueKey('jf-details'));
|
||||
expect(panel, findsOneWidget);
|
||||
// Docked at the right, 400 px.
|
||||
expect(tester.getSize(panel).width, detailsPanelWidth);
|
||||
expect(tester.getTopLeft(panel).dx, 1600 - detailsPanelWidth);
|
||||
expect(h.controller.selected!.itemId, arifuretaId);
|
||||
// Next up is S2E4; the list is its season.
|
||||
expect(find.text('Put it up · S2E4'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('details-episode-$s2e2')), findsOneWidget);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('details-episode-$s2e2')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('details-episode-$s2e2')));
|
||||
await settle(tester);
|
||||
expect(find.text('Put it up · S2E2'), findsOneWidget);
|
||||
await tester.drag(find.byKey(const ValueKey('details-list')), const Offset(0, 2000));
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('details-put-up')));
|
||||
await settle(tester);
|
||||
final m = h.put.single as JellyfinMedia;
|
||||
expect((m.serverId, m.itemId, m.title), (fixtureServerId, s2e2, 'Burning Light'));
|
||||
expect(m.subtitle, endsWith('S2E2'));
|
||||
|
||||
// Another card: the panel's content cross-fades.
|
||||
await tester.tap(find.byKey(const ValueKey('card-$bsEpisode')));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
expect(find.text("Arifureta: From Commonplace to World's Strongest"), findsWidgets,
|
||||
reason: 'the old panel is still fading out');
|
||||
await settle(tester, 10);
|
||||
expect(find.text('Put it up · S2E2'), findsNothing);
|
||||
expect(find.text('Put it up · S1E6'), findsOneWidget);
|
||||
|
||||
// Close: the panel slides away and the selection is gone.
|
||||
await tester.tap(find.byKey(const ValueKey('details-close')));
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.selected, isNull);
|
||||
expect(tester.getSize(find.byType(ClipRect).first).width, isNot(detailsPanelWidth));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('double-click a card puts it up (a series: its next episode)', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1600, 1000));
|
||||
await doubleTap(tester, arifuretaCard());
|
||||
final m = h.put.single as JellyfinMedia;
|
||||
expect(m.itemId, '56d6b3b65c83213fcbaeaf85c9239189'); // S2E4
|
||||
expect(h.controller.selected!.itemId, arifuretaId);
|
||||
await doubleTap(tester, find.byKey(const ValueKey('card-$bsEpisode')));
|
||||
expect((h.put.last as JellyfinMedia).itemId, bsEpisode);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('mark watched and remove from Continue reload the rows', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1600, 1000));
|
||||
final row = find.byKey(const ValueKey('row-continue'));
|
||||
expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('card-$bsEpisode')));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('Put it up · S1E6'), findsOneWidget);
|
||||
final resumes = h.fake.count('GET', '/UserItems/Resume');
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('details-watched')));
|
||||
await settle(tester, 10);
|
||||
expect(h.fake.count('POST', '/UserPlayedItems/$bsEpisode'), 1);
|
||||
expect(h.fake.count('GET', '/UserItems/Resume'), greaterThan(resumes), reason: 'rows reload');
|
||||
|
||||
expect(find.byKey(const ValueKey('details-remove-continue')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('details-remove-continue')));
|
||||
await settle(tester, 10);
|
||||
expect(h.accounts.continueDismissed(fixtureServerId), contains(bsSeries));
|
||||
expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsNothing);
|
||||
expect(find.descendant(of: row, matching: find.text("JoJo's Bizarre Adventure")), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('offline: everything browses, nothing goes up', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1600, 1000), online: false);
|
||||
await tester.tap(arifuretaCard());
|
||||
await settle(tester, 10);
|
||||
final go = find.byKey(const ValueKey('details-put-up'));
|
||||
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
|
||||
expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget);
|
||||
expect(find.text(offlinePutUpHint), findsOneWidget);
|
||||
await doubleTap(tester, arifuretaCard());
|
||||
expect(h.put, isEmpty);
|
||||
|
||||
await tester.tap(find.text('Link'));
|
||||
await settle(tester, 10);
|
||||
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
|
||||
await settle(tester, 10);
|
||||
final linkGo = find.byKey(const ValueKey('link-put-up'));
|
||||
expect(tester.widget<ButtonStyleButton>(linkGo).onPressed, isNull);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('media-link')), matching: find.text(offlinePutUpHint)),
|
||||
findsOneWidget);
|
||||
expect(find.text('How the others get it'), findsNothing);
|
||||
expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works');
|
||||
|
||||
await tester.tap(find.text('Files'));
|
||||
await settle(tester, 10);
|
||||
expect(find.text('files online=false relayed=true'), findsOneWidget);
|
||||
expect(h.put, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('link: preview, how the others get it, put up, recent links, Ctrl+V', (tester) async {
|
||||
final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
|
||||
expect(find.text('How the others get it'), findsOneWidget);
|
||||
const url = 'https://www.youtube.com/watch?v=abc';
|
||||
await tester.enterText(find.byKey(const ValueKey('link-field')), url);
|
||||
await settle(tester, 10);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('link-preview')), matching: find.text('A video')),
|
||||
findsOneWidget);
|
||||
expect(find.text('youtube.com · Chan'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('link-mode-me')));
|
||||
await settle(tester);
|
||||
expect(h.controller.linkRelay, LinkRelay.me);
|
||||
expect(find.text(linkRelayHelp(LinkRelay.me)), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up')));
|
||||
await settle(tester, 10);
|
||||
expect(h.put, [const LinkMedia(url, relay: LinkRelay.me)]);
|
||||
expect(h.recents.links.value.single.title, 'A video');
|
||||
expect(find.byKey(const ValueKey('recent-link-$url')), findsOneWidget);
|
||||
|
||||
// A recent link refills the field.
|
||||
await tester.enterText(find.byKey(const ValueKey('link-field')), '');
|
||||
await settle(tester, 10);
|
||||
await tester.tap(find.byKey(const ValueKey('recent-link-$url')));
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.link.text, url);
|
||||
|
||||
// Ctrl+V outside a field, on another source: the link lands in Link.
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await settle(tester, 10);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'};
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
Focus.of(tester.element(find.byKey(const ValueKey('media-source')))).requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await settle(tester, 10);
|
||||
expect(h.controller.source, MediaKind.link);
|
||||
expect(h.controller.link.text, 'https://cdn.example/b.mkv');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async {
|
||||
await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true);
|
||||
expect(find.text('files online=true relayed=false'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async {
|
||||
final (accounts, _) = await linkedAccounts(http: libraryFake());
|
||||
const pick = JellyfinMedia(
|
||||
serverId: fixtureServerId,
|
||||
baseUrl: 'https://example.invalid',
|
||||
itemId: bsEpisode,
|
||||
title: 'Rice and Thieves and Heroes',
|
||||
subtitle: 'Black Summoner · S1E6');
|
||||
await accounts.rememberPick(pick);
|
||||
final c = MediaPickerController();
|
||||
addTearDown(c.dispose);
|
||||
await restoreLastPick(c, accounts, () => true);
|
||||
expect(c.selected!.itemId, bsEpisode);
|
||||
expect(c.source, MediaKind.jellyfin);
|
||||
final h = Harness(c, accounts, FakeJellyfin());
|
||||
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false);
|
||||
expect(find.byKey(const ValueKey('jf-details')), findsOneWidget);
|
||||
expect(find.text('Black Summoner'), findsWidgets);
|
||||
|
||||
// Change video: the series of the episode playing, its season chosen
|
||||
// and the episode highlighted.
|
||||
final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List;
|
||||
final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map)
|
||||
.cast<String, dynamic>());
|
||||
final h2 = await pumpLibrary(tester, const Size(1600, 1000),
|
||||
start: BrowseStart.seriesOf(fixtureServerId, ep),
|
||||
pick: JellyfinMedia(serverId: fixtureServerId, baseUrl: 'x', itemId: ep.id, title: ep.name));
|
||||
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected, isTrue);
|
||||
final tile = tester.widget<Material>(find
|
||||
.ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material))
|
||||
.first);
|
||||
expect((tile.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent));
|
||||
expect(find.text('Put it up · S2E1'), findsOneWidget);
|
||||
expect(h2.controller.selected!.itemId, ep.id);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final size in const [Size(2560, 1080), Size(1100, 700), Size(900, 600)]) {
|
||||
testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}, panel open, every source',
|
||||
(tester) async {
|
||||
final h = await pumpLibrary(tester, size);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(arifuretaCard());
|
||||
await settle(tester, 10);
|
||||
expect(tester.takeException(), isNull);
|
||||
final panel = find.byKey(const ValueKey('jf-details'));
|
||||
expect(panel, findsOneWidget);
|
||||
// Under 1000 px the panel floats over the library at the right edge.
|
||||
expect(tester.getTopRight(panel).dx, size.width);
|
||||
h.controller.link.text = 'https://cdn.example/some/long/path/Movie.Name.2024.1080p.mkv';
|
||||
for (final s in [MediaKind.link, MediaKind.file, MediaKind.jellyfin]) {
|
||||
h.controller.source = s;
|
||||
await settle(tester, 10);
|
||||
expect(tester.takeException(), isNull, reason: '$s at $size');
|
||||
}
|
||||
h.controller.select(null);
|
||||
await settle(tester, 10);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'dart:io';
|
||||
|
||||
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/controls/controls_probe.dart';
|
||||
import 'package:watch_together/src/controls/player_settings.dart';
|
||||
import 'package:watch_together/src/controls/uosc/controls.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/schedule/animeflow.dart';
|
||||
import 'package:watch_together/src/shaders/shader_presets.dart';
|
||||
import 'package:watch_together/src/shell/avatar.dart';
|
||||
import 'package:watch_together/src/shell/party_strip.dart';
|
||||
import 'package:watch_together/src/shell/settings_tab.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
|
||||
// The player's ⚙ card (both designs, never a media_kit `Video`), the
|
||||
// Settings pages, the host flag in the party UI and invite addresses.
|
||||
|
||||
Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
|
||||
|
||||
void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
}
|
||||
|
||||
Future<void> pick(WidgetTester tester, String key, String item) async {
|
||||
await tester.tap(find.byKey(ValueKey(key)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(item).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final s = AppSettings.instance;
|
||||
final coord = SessionState.instance.coordinator;
|
||||
|
||||
tearDown(() {
|
||||
s.mpvControls.value = false;
|
||||
s.progressLine.value = true;
|
||||
s.shaderPreset.value = ShaderPreset.off;
|
||||
s.shaderQuality.value = ShaderQuality.hq;
|
||||
s.hwdecMode.value = HwdecMode.auto;
|
||||
coord.playback.tracks.value = const [];
|
||||
coord.serverState.value = null;
|
||||
coord.hostFile = null;
|
||||
SessionState.instance.route.value = AppRoute.home;
|
||||
SessionState.instance.recents.value = const [];
|
||||
});
|
||||
|
||||
group('⚙ player settings', () {
|
||||
testWidgets('standard controls: the gear opens every player setting, changes apply live', (tester) async {
|
||||
bigWindow(tester);
|
||||
coord.playback.tracks.value = const [
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||
];
|
||||
await tester.pumpWidget(app(const Align(alignment: Alignment.bottomRight, child: PlayerSettingsButton())));
|
||||
await tester.tap(find.byKey(const ValueKey('player-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
final card = find.byKey(const ValueKey('player-settings-card'));
|
||||
expect(card, findsOneWidget);
|
||||
for (final label in [
|
||||
'Player design',
|
||||
'Progress line when hidden',
|
||||
'Upscaling shader',
|
||||
'Hardware decoding',
|
||||
'Subtitles',
|
||||
'Audio',
|
||||
]) {
|
||||
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
|
||||
}
|
||||
// Subtitles off (none selected), audio on the selected track.
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')),
|
||||
findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
||||
findsOneWidget);
|
||||
|
||||
await pick(tester, 'player-design', 'uosc');
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
||||
await tester.pump();
|
||||
expect(s.progressLine.value, isFalse);
|
||||
await pick(tester, 'shader-preset', 'Mode B');
|
||||
expect(s.shaderPreset.value, ShaderPreset.b);
|
||||
await pick(tester, 'shader-quality', 'Fast');
|
||||
expect(s.shaderQuality.value, ShaderQuality.fast);
|
||||
await pick(tester, 'hwdec', 'Off');
|
||||
expect(s.hwdec.value, 'no');
|
||||
// Esc closes it.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
expect(card, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('uosc controls: the gear opens the card and keeps the controls up', (tester) async {
|
||||
bigWindow(tester);
|
||||
s.mpvControls.value = true;
|
||||
await tester.pumpWidget(app(UoscControls(
|
||||
hideAfter: const Duration(seconds: 1),
|
||||
timeline: (context, expanded) => const SizedBox(height: 30),
|
||||
right: const [UoscPlayerSettingsButton()],
|
||||
)));
|
||||
final controls = tester.state<UoscControlsState>(find.byType(UoscControls));
|
||||
await tester.tap(find.byKey(const ValueKey('uosc-player-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
||||
expect(find.text('uosc'), findsOneWidget, reason: 'the current design');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(controls.visible, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('uosc: hidden controls keep the line unless the setting is off', (tester) async {
|
||||
await tester.pumpWidget(app(UoscControls(
|
||||
hideAfter: const Duration(milliseconds: 100),
|
||||
timeline: (context, expanded) => const SizedBox(height: 2),
|
||||
)));
|
||||
double opacity() =>
|
||||
tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('uosc-timeline-wrap'))).opacity;
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(opacity(), 1, reason: 'hidden, line on');
|
||||
s.setProgressLine(false);
|
||||
await tester.pump();
|
||||
expect(opacity(), 0, reason: 'hidden, line off');
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(400, 300));
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.moveTo(const Offset(410, 300));
|
||||
await tester.pump();
|
||||
expect(opacity(), 1, reason: 'controls up: the full timeline');
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('standard controls: the probe follows media_kit\'s show / hide rule', (tester) async {
|
||||
final shown = ValueNotifier(false);
|
||||
addTearDown(shown.dispose);
|
||||
await tester.pumpWidget(app(ControlsHoverProbe(
|
||||
shown: shown,
|
||||
hideAfter: const Duration(seconds: 3),
|
||||
child: const SizedBox.expand(),
|
||||
)));
|
||||
await tester.pump();
|
||||
expect(shown.value, isTrue, reason: 'up on mount');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(shown.value, isFalse, reason: 'down 3 s later');
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(400, 300));
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.moveTo(const Offset(420, 300));
|
||||
await tester.pump();
|
||||
expect(shown.value, isTrue, reason: 'mouse movement');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(shown.value, isFalse);
|
||||
});
|
||||
|
||||
group('Settings pages', () {
|
||||
Future<void> pumpSettings(WidgetTester tester, SettingsSection section,
|
||||
{Future<AnimeFlowMe> Function()? me, Size size = const Size(1280, 1400)}) async {
|
||||
bigWindow(tester, size);
|
||||
await tester.pumpWidget(app(SettingsTab(
|
||||
section: section,
|
||||
onSection: (_) {},
|
||||
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||
animeFlowMe: me ?? () async => throw const AnimeFlowException(401, AnimeFlowErrorCode.missingToken, 'x'),
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('every section renders as cards, with icons in the nav', (tester) async {
|
||||
for (final section in SettingsSection.values) {
|
||||
await pumpSettings(tester, section);
|
||||
expect(find.text(section.label), findsWidgets, reason: section.name);
|
||||
expect(tester.takeException(), isNull, reason: section.name);
|
||||
}
|
||||
expect(SettingsSection.values.map((s) => s.label), [
|
||||
'Profile',
|
||||
'Party & network',
|
||||
'Player',
|
||||
'Jellyfin & integrations',
|
||||
'Notifications & sounds',
|
||||
'Updates',
|
||||
'Keyboard shortcuts',
|
||||
'About',
|
||||
]);
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Player: the same rows as the ⚙, plus a pointer to it', (tester) async {
|
||||
await pumpSettings(tester, SettingsSection.player);
|
||||
expect(find.textContaining("also in the player's ⚙"), findsOneWidget);
|
||||
for (final key in ['player-design', 'progress-line', 'shader-preset', 'hwdec']) {
|
||||
expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key);
|
||||
}
|
||||
expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player');
|
||||
expect(find.text('Standard'), findsOneWidget);
|
||||
await pick(tester, 'player-design', 'uosc');
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Party & network: host defaults and recent servers with remove', (tester) async {
|
||||
SessionState.instance.recents.value = const [
|
||||
RecentServer(hosted: false, host: 'vps.example', port: '8080', secret: 'pw'),
|
||||
RecentServer(hosted: true, host: '127.0.0.1', port: '9000'),
|
||||
];
|
||||
await pumpSettings(tester, SettingsSection.network);
|
||||
expect(find.text('Default port'), findsOneWidget);
|
||||
expect(find.text('vps.example:8080'), findsOneWidget);
|
||||
expect(find.text('Hosted on port 9000'), findsOneWidget);
|
||||
await tester.enterText(find.byKey(const ValueKey('host-port')), '70000');
|
||||
await tester.pump();
|
||||
expect(find.text('A number from 1 to 65535'), findsOneWidget);
|
||||
await tester.enterText(find.byKey(const ValueKey('host-port')), '9100');
|
||||
await tester.pump();
|
||||
expect(SessionState.instance.lastForm.hostPort, '9100');
|
||||
await tester.tap(find.descendant(
|
||||
of: find.byKey(const ValueKey('recent-vps.example:8080')), matching: find.byTooltip('Remove')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('vps.example:8080'), findsNothing);
|
||||
SessionState.instance.lastForm.hostPort = '8080';
|
||||
});
|
||||
|
||||
testWidgets('Integrations: AnimeFlow says who you are connected as, or why not', (tester) async {
|
||||
await pumpSettings(tester, SettingsSection.integrations,
|
||||
me: () async => const AnimeFlowMe(
|
||||
userId: 'u1', name: 'foxyz', scopes: {AnimeFlowScope.view, AnimeFlowScope.request}));
|
||||
expect(find.text('connected as foxyz · can view, request'), findsOneWidget);
|
||||
// A fresh tree: the card keeps its first answer otherwise.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await pumpSettings(tester, SettingsSection.integrations,
|
||||
me: () async => throw const AnimeFlowException(
|
||||
401, AnimeFlowErrorCode.missingToken, 'No linked Jellyfin account for AnimeFlow'));
|
||||
expect(find.text('No linked Jellyfin account for AnimeFlow'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('narrow: icon chips instead of the side nav', (tester) async {
|
||||
await pumpSettings(tester, SettingsSection.network, size: const Size(700, 1000));
|
||||
expect(find.byType(ChoiceChip), findsWidgets);
|
||||
expect(find.byType(ListTile).evaluate().where((e) => e.widget.key == const ValueKey('settings-profile')),
|
||||
isEmpty, reason: 'no side nav');
|
||||
final chip = tester.widget<ChoiceChip>(find.byKey(const ValueKey('settings-network')));
|
||||
expect(chip.selected, isTrue);
|
||||
expect((chip.avatar! as Icon).icon, Icons.hub_outlined);
|
||||
expect(find.text('Default port'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('party', () {
|
||||
test('invite addresses: LAN IPv4 with the port, private first, no loopback', () {
|
||||
final list = inviteAddresses([
|
||||
InternetAddress('127.0.0.1'),
|
||||
InternetAddress('100.64.1.2'),
|
||||
InternetAddress('192.168.1.20'),
|
||||
InternetAddress('169.254.3.4'),
|
||||
InternetAddress('::1'),
|
||||
InternetAddress('10.0.0.5'),
|
||||
InternetAddress('192.168.1.20'),
|
||||
], '8080');
|
||||
expect(list, ['192.168.1.20:8080', '10.0.0.5:8080', '100.64.1.2:8080']);
|
||||
expect(inviteAddresses(const [], '8080'), isEmpty);
|
||||
});
|
||||
|
||||
void online(List<MemberView> members) {
|
||||
coord.serverState.value = StateBroadcast(
|
||||
eventId: 1,
|
||||
transport: 'held',
|
||||
position: 0,
|
||||
serverTime: DateTime.now().toUtc(),
|
||||
mediaId: 0,
|
||||
members: members,
|
||||
);
|
||||
SessionState.instance.route.value = AppRoute.session;
|
||||
}
|
||||
|
||||
testWidgets('the state\'s host flag crowns that member for everyone', (tester) async {
|
||||
online(const [
|
||||
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, stale: false, host: true),
|
||||
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false),
|
||||
]);
|
||||
final avatars = {for (final a in partyAvatars(coord)) a.name: a};
|
||||
expect(avatars['Hana']!.host, isTrue);
|
||||
expect(avatars['Bob']!.host, isFalse);
|
||||
bigWindow(tester);
|
||||
await tester.pumpWidget(app(const Center(child: MembersCard())));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.text('host')),
|
||||
findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
|
||||
findsNothing);
|
||||
// The host cannot be kicked: no Kick in its menu.
|
||||
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Kick'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
|
||||
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0, stale: false)]);
|
||||
coord.hostFile = (_) async => null;
|
||||
SessionState.instance.lastForm.hostPort = '8080';
|
||||
bigWindow(tester);
|
||||
await tester.pumpWidget(app(Center(
|
||||
child: MembersCard(lanAddresses: (port) async => ['192.168.1.20:$port', '10.0.0.5:$port']),
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Copy invite address'), findsOneWidget);
|
||||
expect(find.text('192.168.1.20:8080'), findsOneWidget);
|
||||
expect(find.text('10.0.0.5:8080'), findsOneWidget);
|
||||
expect(find.textContaining('port 8080 forwarded'), findsOneWidget);
|
||||
expect(find.textContaining('VPS'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -301,17 +301,21 @@ void main() {
|
||||
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
|
||||
AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal);
|
||||
final at = DateTime.now();
|
||||
final names = ['Bob', 'Bob Jr'];
|
||||
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at), names, who);
|
||||
// The actor is exact: "Bob" is not mistaken for "Bob Jr".
|
||||
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at, actor: 'Bob Jr'), who);
|
||||
expect(chat.who!.name, 'Bob Jr');
|
||||
expect(chat.text, 'hi there');
|
||||
expect(chat.chat, isTrue);
|
||||
final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at), names, who);
|
||||
final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at, actor: 'Bob'), who);
|
||||
expect(joined.who!.name, 'Bob');
|
||||
expect(joined.text, 'joined');
|
||||
expect(joined.icon, Icons.login);
|
||||
final app = shellToastFor(ToastItem(id: 3, text: 'Video froze — reloaded', at: at), names, who);
|
||||
// A name that is a prefix of a sentence is not guessed without an actor.
|
||||
final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who);
|
||||
expect(app.who, isNull);
|
||||
expect(app.text, 'Bob joined');
|
||||
final froze = shellToastFor(ToastItem(id: 4, text: 'Video froze — reloaded', at: at), who);
|
||||
expect(froze.who, isNull);
|
||||
});
|
||||
|
||||
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
|
||||
|
||||
@@ -447,7 +447,7 @@ void main() {
|
||||
expect(find.text('material'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Settings: "Experimental: mpv-style controls", off by default', (tester) async {
|
||||
testWidgets('Settings: "Player design", Standard by default', (tester) async {
|
||||
final s = AppSettings.instance;
|
||||
s.mpvControls.value = false;
|
||||
addTearDown(() => s.mpvControls.value = false);
|
||||
@@ -464,12 +464,14 @@ void main() {
|
||||
),
|
||||
),
|
||||
));
|
||||
final toggle = find.byKey(const ValueKey('mpv-controls'));
|
||||
expect(find.text('Experimental: mpv-style controls'), findsOneWidget);
|
||||
expect(tester.widget<SwitchListTile>(toggle).value, isFalse);
|
||||
await tester.tap(toggle);
|
||||
await tester.pump();
|
||||
final design = find.byKey(const ValueKey('player-design'));
|
||||
expect(find.text('Player design'), findsOneWidget);
|
||||
expect(tester.widget<DropdownButton<bool>>(design).value, isFalse);
|
||||
await tester.tap(design);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('uosc').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
expect(tester.widget<SwitchListTile>(toggle).value, isTrue);
|
||||
expect(tester.widget<DropdownButton<bool>>(design).value, isTrue);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user