import 'package:flutter_test/flutter_test.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:material_ui/material_ui.dart'; import '../../support/fake_jellyfin.dart'; import '../../support/fake_studyset_gateway.dart'; import '../../support/pump_app.dart'; import '../../support/sample_episode.dart'; import '../../support/test_dependencies.dart'; void main() { late FakeJellyfinGateway jellyfin; late MemoryJellyfinStore store; setUp(() { jellyfin = sampleJellyfin(); store = MemoryJellyfinStore(); }); Future show( WidgetTester tester, String location, { String? suggested, }) async { tester.view.physicalSize = const Size(1200, 1400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final studysets = FakeStudysetGateway() ..receiveRemoteChange( 'japanese', encodeStudysetPayload( StudysetContent( title: 'Japanese', description: RichContent.empty(), frontLanguage: 'ja', backLanguage: 'en', ), ), ); await pumpApp( tester, testDependencies( studysetGateway: studysets, dictionaryGateway: sampleDictionary(), jellyfinGateway: jellyfin, jellyfinStore: store, jellyfinSuggestion: JellyfinSuggestion(() async => suggested), ), location: location, ); await tester.pumpAndSettle(); } /// Finds the server, from Settings or the studyset. Future findServer(WidgetTester tester) async { await tester.enterText( find.widgetWithText(TextField, 'Server address'), 'jellyfin.local:8920', ); await tester.tap(find.text('Continue')); await tester.pumpAndSettle(); } testWidgets('Settings connects a server over HTTPS with a name and ' 'password, kept on the device, and signs out of it', (tester) async { await show(tester, RoutePaths.settings); await tester.scrollUntilVisible( find.widgetWithText(TextField, 'Server address'), 200, scrollable: find.byType(Scrollable).first, ); await tester.enterText( find.widgetWithText(TextField, 'Server address'), 'nothing.local', ); await tester.tap(find.text('Continue')); await tester.pumpAndSettle(); expect( find.text('No server answered at that address over HTTPS.'), findsOneWidget, ); await tester.enterText( find.widgetWithText(TextField, 'Server address'), 'http://jellyfin.local:8920', ); await tester.tap(find.text('Continue')); await tester.pumpAndSettle(); expect( find.text( "That address isn't encrypted, and your password would travel in " "the clear. Use the server's HTTPS address.", ), findsOneWidget, ); await findServer(tester); expect(find.text('Living room'), findsOneWidget); await tester.enterText(find.widgetWithText(TextField, 'User name'), 'mika'); await tester.enterText(find.widgetWithText(TextField, 'Password'), 'wrong'); await tester.tap(find.text('Sign in')); await tester.pumpAndSettle(); expect(find.text('The user name or password is wrong.'), findsOneWidget); await tester.enterText( find.widgetWithText(TextField, 'Password'), 'secret', ); await tester.tap(find.text('Sign in')); await tester.pumpAndSettle(); expect(find.text('mika on Living room'), findsOneWidget); expect(store.kept?.account?.token, 'token-1'); await tester.tap(find.byTooltip('Sign out of Jellyfin')); await tester.pumpAndSettle(); expect(find.widgetWithText(TextField, 'Server address'), findsOneWidget); expect(jellyfin.signedOut, ['token-1']); }); testWidgets('a code approved in another Jellyfin app signs in', ( tester, ) async { await show(tester, RoutePaths.jellyfin('japanese')); await findServer(tester); await tester.tap(find.text('Sign in with a code')); // It waits, spinning, until the code is approved. await tester.pump(); await tester.pump(); expect(find.text('123456'), findsOneWidget); await tester.pump(const Duration(seconds: 3)); expect(find.text('123456'), findsOneWidget); jellyfin.approved = true; await tester.pump(const Duration(seconds: 3)); await tester.pumpAndSettle(); expect(find.text('Anime'), findsOneWidget); expect(store.kept?.account?.token, 'token-2'); }); testWidgets('a music library lists its albums and songs; a song without ' 'lyrics is greyed and opens nothing, one with lyrics plays', ( tester, ) async { jellyfin = sampleMusic(); store = signedInJellyfin(); await show( tester, RoutePaths.jellyfin( 'japanese', parentId: 'album-1', name: 'Sample Album', ), ); expect(find.text('Sample Song'), findsOneWidget); expect(find.textContaining('No lyrics'), findsOneWidget); await tester.tap(find.text('Sample Song (Instrumental)')); await tester.pumpAndSettle(); expect(find.text('Sample Song (Instrumental)'), findsOneWidget); await tester.tap(find.text('Sample Song')); for (var frame = 0; frame < 6; frame++) { await tester.pump(const Duration(milliseconds: 500)); } expect(find.text('Words in this song'), findsOneWidget); }); testWidgets('a show tied to the studyset comes first next time; an ' 'episode opens to be watched', (tester) async { store = signedInJellyfin(); await show(tester, RoutePaths.jellyfin('japanese')); expect(find.text('Tied to this studyset'), findsNothing); await tester.tap(find.text('Anime')); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Tie to this studyset')); await tester.pumpAndSettle(); expect(find.byTooltip('Untie from this studyset'), findsOneWidget); await tester.pageBack(); await tester.pumpAndSettle(); expect(find.text('Tied to this studyset'), findsOneWidget); await tester.tap(find.text('Sample Journey')); await tester.pumpAndSettle(); await tester.tap(find.text('Season 1')); await tester.pumpAndSettle(); expect(find.text('S1 E1 · 24 min'), findsOneWidget); await tester.tap(find.text('The Station')); await tester.pumpAndSettle(); expect(find.byType(WatchScreen), findsOneWidget); }); testWidgets('a search finds shows and episodes by their name', ( tester, ) async { store = signedInJellyfin(); await show(tester, RoutePaths.jellyfin('japanese')); await tester.enterText(find.byType(TextField), 'station'); await tester.pump(const Duration(milliseconds: 300)); await tester.pumpAndSettle(); expect(find.text('The Station'), findsOneWidget); expect(find.text('Libraries'), findsNothing); }); testWidgets("the studyset's Add material offers Jellyfin only once this " 'device is signed in to a server (in Settings), as the assistant ' 'shows only once switched on', (tester) async { await show(tester, RoutePaths.studyset('japanese')); await tester.tap(find.text('Add material')); await tester.pumpAndSettle(); expect(find.text('Create a path'), findsOneWidget); expect(find.text('From Jellyfin'), findsNothing); }); testWidgets('signed in, Add material › From Jellyfin opens its ' 'libraries', (tester) async { store = signedInJellyfin(); await show(tester, RoutePaths.studyset('japanese')); await tester.tap(find.text('Add material')); await tester.pumpAndSettle(); await tester.tap(find.text('From Jellyfin')); await tester.pump(); await tester.pumpAndSettle(); expect(find.text('Libraries'), findsOneWidget); }); group('a Jellyfin server the instance suggests (decided 2026-10-13)', () { setUp(() { jellyfin ..shownUsers = const [ JellyfinPublicUser(id: 'user-2', name: 'Demo'), JellyfinPublicUser(id: 'user-1', name: 'mika'), ] ..withoutPassword = const {'Demo'}; }); testWidgets('Add material offers it before signing in; the sign-in ' "finds it by itself and lists the server's public people, one " 'without a password signed in with the password left empty', ( tester, ) async { await show( tester, RoutePaths.studyset('japanese'), suggested: 'https://jellyfin.local:8920', ); await tester.tap(find.text('Add material')); await tester.pumpAndSettle(); await tester.tap(find.text('From Jellyfin')); await tester.pump(); await tester.pumpAndSettle(); expect(find.text('Living room'), findsOneWidget); expect(find.text('Demo'), findsOneWidget); await tester.tap(find.text('Demo')); await tester.pumpAndSettle(); expect(store.kept?.account, isNull); await tester.tap(find.text('Sign in')); await tester.pumpAndSettle(); expect(find.text('Libraries'), findsOneWidget); expect(store.kept?.account?.userName, 'Demo'); }); testWidgets('a tap names one in the form, which asks for the ' 'password', (tester) async { await show( tester, RoutePaths.jellyfin('japanese'), suggested: 'https://jellyfin.local:8920', ); await tester.tap(find.text('mika')); await tester.pumpAndSettle(); expect( tester .widget(find.widgetWithText(TextField, 'User name')) .controller! .text, 'mika', ); expect( tester .widget(find.widgetWithText(TextField, 'Password')) .focusNode! .hasFocus, isTrue, ); expect(store.kept?.account, isNull); }); }); }