import 'dart:io'; 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:starsync/src/controls/frame.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart'; import 'package:starsync/src/controls/player_settings.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/schedule/animeflow.dart'; import 'package:starsync/src/shaders/shader_presets.dart'; import 'package:starsync/src/shell/avatar.dart'; import 'package:starsync/src/shell/party_strip.dart'; import 'package:starsync/src/shell/settings_tab.dart'; import 'package:starsync/src/state/settings.dart'; import 'package:starsync/src/update/update_controller.dart'; import 'package:starsync/src/session/invite.dart'; import 'package:starsync/src/session/navigation.dart' show Place; import 'package:starsync/src/state/connect_prefs.dart' show RecentServer; import 'test_graph.dart'; /// This file's composition root (see test_graph.dart). final _graph = testGraph(); // The player settings card (both skins, 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 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 = _graph.coordinator; tearDown(() { s.controlsSkin.value = ControlsSkin.standard; s.timeDisplay.value = TimeDisplay.elapsed; s.controlsSize.value = ControlsSize.normal; s.controlsHideSecs.value = defaultControlsHideSecs; s.hiddenControls.value = const {}; 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; _graph.navigation.place.value = Place.none; _graph.forms.recents.value = const []; }); Widget opener() => Builder( builder: (context) => TextButton( key: const ValueKey('open-settings'), onPressed: () => showPlayerSettings(context), child: const Text('settings'), ), ); group('player settings card', () { testWidgets('lists 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(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener())))); await tester.tap(find.byKey(const ValueKey('open-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', 'Minimal'); expect(s.controlsSkin.value, ControlsSkin.minimal); 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('keeps the controls up while open', (tester) async { bigWindow(tester); await tester.pumpWidget(scoped(_graph, app(ControlsFrame( hideAfter: const Duration(seconds: 1), builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()), )))); final controls = tester.state(find.byType(ControlsFrame)); await tester.tap(find.byKey(const ValueKey('open-settings'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget); expect(find.text('Standard'), findsOneWidget, reason: 'the current skin'); 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); }); }); group('Settings pages', () { Future pumpSettings(WidgetTester tester, SettingsSection section, {Future Function()? me, Size size = const Size(1280, 1400)}) async { bigWindow(tester, size); await tester.pumpWidget(scoped(_graph, 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 card\'s rows, a pointer to it, and the controls\' choices', (tester) async { await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400)); expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget); for (final key in [ 'player-design', 'progress-line', 'shader-preset', 'hwdec', 'time-display', 'controls-size', 'controls-hide', for (final c in OptionalControl.values) 'bar-${c.name}', ]) { 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', 'Minimal'); expect(s.controlsSkin.value, ControlsSkin.minimal); await pick(tester, 'time-display', 'Remaining'); expect(s.timeDisplay.value, TimeDisplay.remaining); await pick(tester, 'controls-size', 'Large'); expect(s.controlsSize.value, ControlsSize.large); await pick(tester, 'controls-hide', '5 s'); expect(s.controlsHideSecs.value, 5); await tester.tap(find.byKey(const ValueKey('bar-chat'))); await tester.pump(); expect(s.hiddenControls.value, {OptionalControl.chat}); await tester.tap(find.byKey(const ValueKey('bar-chat'))); await tester.pump(); expect(s.hiddenControls.value, isEmpty); }); testWidgets('Party & network: your server\'s port and password, recent servers with remove', (tester) async { _graph.forms.recents.value = const [ RecentServer(host: 'vps.example', port: '8080', secret: 'pw'), RecentServer(host: 'friend.example', port: '9000'), ]; await pumpSettings(tester, SettingsSection.network); expect(find.text('Your server'), findsOneWidget); expect(find.text('Port'), findsOneWidget); expect(find.text('vps.example:8080'), findsOneWidget); expect(find.text('friend.example: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(_graph.forms.form.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); _graph.forms.form.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(scoped(_graph, 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(find.byKey(const ValueKey('settings-network'))); expect(chip.selected, isTrue); expect((chip.avatar! as Icon).icon, Icons.hub_outlined); expect(find.text('Port'), findsOneWidget); }); }); group('party', () { test('invite addresses: home network first; no proxy fake-IP, link-local, loopback or container bridge', () { ({String name, InternetAddress address}) at(String name, String ip) => (name: name, address: InternetAddress(ip)); final list = inviteAddresses([ at('lo', '127.0.0.1'), at('Meta', '198.18.0.1'), // a TUN proxy's fake-IP range (198.18.0.0/15) at('utun', '198.19.255.2'), at('tailscale0', '100.64.1.2'), at('docker0', '172.17.0.1'), at('br-3f2a', '172.18.0.1'), at('virbr0', '192.168.122.1'), at('eth1', '10.0.0.5'), at('wlan0', '169.254.3.4'), at('eth0', '192.168.1.20'), at('eth0', '192.168.1.20'), at('eth0', '::1'), at('wan', '203.0.113.7'), ]); expect(list, ['192.168.1.20', '10.0.0.5', '100.64.1.2', '203.0.113.7']); expect(inviteAddresses(const []), isEmpty); expect(isFakeIp(InternetAddress('198.19.1.1').rawAddress), isTrue); expect(isFakeIp(InternetAddress('198.20.1.1').rawAddress), isFalse); }); test('an invite: the address, whether to ask for a password, the version; pasted back', () { const invite = Invite(host: '192.168.1.20', port: 8080, password: true, version: '1.3.0'); expect(invite.address, '192.168.1.20:8080'); expect(invite.text, 'Join me on Starsync: 192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0'); expect(invite.fp, isNull, reason: 'reserved for pinned certificates'); expect(addressIn(invite.text), (host: '192.168.1.20', port: '8080')); expect(addressIn('vps.example.com:9000'), (host: 'vps.example.com', port: '9000')); expect(addressIn('[fe80::1]:8080'), (host: 'fe80::1', port: '8080')); expect(addressIn('just a host'), isNull); }); void online(List members) { coord.serverState.value = StateBroadcast( eventId: 1, transport: 'held', position: 0, serverTime: DateTime.now().toUtc(), mediaId: 0, members: members, ); _graph.navigation.place.value = Place.friend; } 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, host: true), MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0), ]); 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(scoped(_graph, 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 can be sat out like anyone. await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More'))); await tester.pumpAndSettle(); expect(find.text('Sit out'), findsOneWidget); }); testWidgets('your server, not shared: only you, Invite; shared: the invite and Stop sharing', (tester) async { online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0)]); _graph.navigation.place.value = Place.yours; final yours = _graph.yourServer; yours.port.value = 40000; // as if it runs addTearDown(() { yours.port.value = null; yours.shared.value = null; }); bigWindow(tester); await tester.pumpWidget(scoped(_graph, app(const Center(child: MembersCard())))); await tester.pumpAndSettle(); expect(find.text('Your server'), findsOneWidget); expect(find.textContaining('Only you can reach it'), findsOneWidget); expect(find.widgetWithText(TextButton, 'Invite'), findsOneWidget); expect(find.byKey(const ValueKey('stop-sharing')), findsNothing); yours.shared.value = const Invite(host: '192.168.1.20', port: 8080, version: '1.3.0'); await tester.pumpAndSettle(); expect(find.text('192.168.1.20:8080'), findsOneWidget); expect(find.text('Copy invite'), findsOneWidget); expect(find.byKey(const ValueKey('stop-sharing')), findsOneWidget); expect(find.textContaining('port 8080 forwarded'), findsOneWidget); expect(find.textContaining('VPS'), findsOneWidget); }); }); }