import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.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/core/video/media_players.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:material_ui/material_ui.dart'; import '../../support/fake_card_gateway.dart'; import '../../support/fake_jellyfin.dart'; import '../../support/fake_studyset_gateway.dart'; import '../../support/fake_tag_gateway.dart'; import '../../support/pump_app.dart'; import '../../support/sample_episode.dart'; import '../../support/test_dependencies.dart'; void main() { late FakeCardGateway cardGateway; late FakeTagGateway tagGateway; late CardRepository cards; late TagRepository tags; late FakeJellyfinGateway jellyfin; late FakeMediaPlayers players; setUp(() { cardGateway = FakeCardGateway(); tagGateway = FakeTagGateway(); cards = CardRepository(cardGateway); tags = TagRepository(tagGateway); jellyfin = sampleJellyfin(); players = FakeMediaPlayers(); }); tearDown(() async { await cards.dispose(); await tags.dispose(); }); /// Opens the sample episode from the Japanese studyset, in a window of /// [size]. Future watch( WidgetTester tester, { Size size = const Size(1400, 900), bool playerAvailable = true, JellyfinPlayback? playback, Map<(String, int), String>? vtt, List audio = const [], }) async { if (playback != null || vtt != null) { jellyfin = sampleJellyfin( playback: playback ?? sampleEpisodePlayback, vtt: vtt ?? {('episode-1', 2): sampleJapanese, ('episode-1', 3): sampleEnglish}, ); } if (audio.isNotEmpty) { players = FakeMediaPlayers(audio: audio); } tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final studysets = FakeStudysetGateway() ..receiveRemoteChange( 'japanese', encodeStudysetPayload( StudysetContent( title: 'Japanese', description: RichContent.empty(), frontLanguage: 'ja', backLanguage: 'en', ), ), ); if (!playerAvailable) { players = FakeMediaPlayers(available: false); } await pumpApp( tester, testDependencies( studysetGateway: studysets, cardGateway: cardGateway, tagGateway: tagGateway, dictionaryGateway: sampleDictionary(), jellyfinGateway: jellyfin, jellyfinStore: signedInJellyfin(), mediaPlayers: players, ), location: RoutePaths.watch('japanese', 'episode-1'), ); await tester.pumpAndSettle(); } /// Plays on to [position]. Future playTo(WidgetTester tester, Duration position) async { players.last!.moveTo(position); await tester.pumpAndSettle(); } testWidgets('the line shown is cut into words, their readings above, its ' 'translation beneath; it follows the video', (tester) async { await watch(tester); expect(players.opened.single.headers['Authorization'], contains('token-1')); expect(jellyfin.reports.single.started, isTrue); await playTo(tester, const Duration(seconds: 2)); expect(find.text('友達'), findsOneWidget); expect(find.text('ともだち'), findsOneWidget); // 会いました: 会 read あ, its ending as it stands. expect(find.text('会'), findsOneWidget); expect(find.text('あ'), findsOneWidget); expect(find.text('いました'), findsOneWidget); expect(find.text('Yesterday I met a friend at the station.'), findsOne); // Between lines the last one stays. await playTo(tester, const Duration(milliseconds: 5500)); expect(find.text('友達'), findsOneWidget); await playTo(tester, const Duration(seconds: 12)); expect(find.text('旅'), findsOneWidget); expect(find.text('友達'), findsNothing); }); testWidgets('a word tapped pauses the video and shows what it is a form ' 'of; added, it is a flashcard with its line and translation as its ' 'first hint, tagged with the show, and is marked from then on', ( tester, ) async { await watch(tester); await players.last!.play(); await playTo(tester, const Duration(seconds: 2)); await tester.tap(find.text('いました')); await tester.pumpAndSettle(); expect(players.last!.state.value.playing, isFalse); expect(find.text('A form of 会う'), findsOneWidget); await tester.tap(find.text('Add')); await tester.pumpAndSettle(); expect(find.text('会う added, with this line'), findsOneWidget); final card = (await cards.cardsOf('japanese')).single; expect(card.content.type, CardType.vocabulary); expect(card.content.entry, (source: 'jmdict', id: '6')); expect( card.content.hints.first.plainText, allOf( contains('昨日、駅で友達に会いました。'), contains('Yesterday I met a friend at the station.'), ), ); final tagging = await tags.read(); expect( [for (final tag in tagging.of(card.id)) tag.name], containsAll(['Sample Journey', 'JLPT-N5']), ); expect(find.byTooltip('In this studyset'), findsOneWidget); }); testWidgets("the video's words are listed, the most frequent first, " 'particles left out; one opens its first line', (tester) async { await watch(tester); expect(find.text('Words in this video'), findsOneWidget); final words = [ for (final tile in tester.widgetList( find.descendant( of: find.byType(CustomScrollView), matching: find.byType(ListTile), ), )) (tile.title! as Text).textSpan!.toPlainText().split(' ').first, ]; // 駅 and 友達 come twice; で, に, を, けど and だ are grammar. expect(words.take(2).toSet(), {'駅', '友達'}); expect(words, isNot(contains('で'))); expect(words, contains('待つ')); await tester.tap(find.textContaining('待つ', findRichText: true).first); await tester.pumpAndSettle(); expect(players.last!.state.value.position, const Duration(seconds: 16)); expect(find.text('A form of 待つ'), findsOneWidget); }); testWidgets('the next line, the one before and the same again play from ' 'their start; a line can end with a pause', (tester) async { await watch(tester); await playTo(tester, const Duration(seconds: 7)); await tester.tap(find.byTooltip('Next line')); await tester.pumpAndSettle(); expect(players.last!.state.value.position, const Duration(seconds: 11)); expect(players.last!.state.value.playing, isTrue); await tester.tap(find.byTooltip('Repeat line')); await tester.pumpAndSettle(); expect(players.last!.state.value.position, const Duration(seconds: 11)); await tester.tap(find.byTooltip('Previous line')); await tester.pumpAndSettle(); expect(players.last!.state.value.position, const Duration(seconds: 6)); await tester.tap(find.text('Pause after each line')); await tester.pumpAndSettle(); await playTo(tester, const Duration(milliseconds: 9900)); expect(players.last!.state.value.playing, isFalse); }); testWidgets('leaving tells the server where the video stopped', ( tester, ) async { await watch(tester); await playTo(tester, const Duration(seconds: 12)); GoRouter.of(tester.element(find.byType(WatchScreen))).go(RoutePaths.home); await tester.pumpAndSettle(); expect(jellyfin.reports.last.stopped, isTrue); expect(jellyfin.reports.last.position, const Duration(seconds: 12)); expect(players.last!.disposed, isTrue); }); testWidgets('without a player the subtitles still show, and the screen ' 'says why the video does not', (tester) async { await watch(tester, playerAvailable: false); expect(find.textContaining("Videos can't play on this device"), findsOne); expect(find.text('昨日'), findsNothing); expect(find.text('Words in this video'), findsOneWidget); }); testWidgets("the sound is in the studyset's language, whichever track the " 'file plays first; the menu switches it', (tester) async { await watch( tester, playback: const JellyfinPlayback( itemId: 'episode-1', mediaSourceId: 'episode-1', playSessionId: 'session-1', runTime: Duration(minutes: 24), subtitles: [ JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'), JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'), ], audio: [ JellyfinAudioTrack( index: 4, title: 'German', language: 'ger', isDefault: true, ), JellyfinAudioTrack( index: 5, title: 'English', language: 'eng', isDefault: true, ), JellyfinAudioTrack( index: 6, title: 'Japanese [Original]', language: 'jpn', isDefault: true, ), ], ), // As Android names them. audio: const [ MediaAudioTrack(id: 'a', language: 'de'), MediaAudioTrack(id: 'b', language: 'en'), MediaAudioTrack(id: 'c', language: 'ja'), ], ); expect(players.last!.selectedAudio?.id, 'c'); await tester.tap(find.byTooltip('Audio and subtitles')); await tester.pumpAndSettle(); await tester.tap(find.text('Audio')); await tester.pumpAndSettle(); await tester.tap(find.text('German')); await tester.pump(); await tester.pumpAndSettle(); expect(players.last!.selectedAudio?.id, 'a'); }); testWidgets("a forced track, which only covers what the sound doesn't " 'say, is never the one read', (tester) async { await watch( tester, playback: const JellyfinPlayback( itemId: 'episode-1', mediaSourceId: 'episode-1', playSessionId: 'session-1', runTime: Duration(minutes: 24), subtitles: [ JellyfinSubtitleTrack( index: 7, title: 'Japanese (forced)', language: 'jpn', isDefault: true, isForced: true, ), JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'), JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'), ], ), ); await playTo(tester, const Duration(seconds: 2)); expect(find.text('友達'), findsOneWidget); }); testWidgets('a line takes the whole width, its translation on one line ' 'too; the video keeps its size however long the line is', ( tester, ) async { final long = List.filled(8, '昨日、駅で友達に会いました。').join(); await watch( tester, vtt: { ('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n旅\n\n' '00:00:06.000 --> 00:00:10.000\n$long\n', ('episode-1', 3): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA journey.\n\n' '00:00:06.000 --> 00:00:10.000\n' 'So I say, "Here you go!",\nand I lent it to him.\n', }, ); await playTo(tester, const Duration(seconds: 2)); final controls = tester.getTopLeft(find.byTooltip('Previous line')); await playTo(tester, const Duration(seconds: 7)); expect( find.text('So I say, "Here you go!", and I lent it to him.'), findsOneWidget, ); expect(tester.getTopLeft(find.byTooltip('Previous line')), controls); expect(tester.takeException(), isNull); }); testWidgets('resizing the window, across every size class, keeps the ' 'video where it is', (tester) async { await watch(tester); await playTo(tester, const Duration(minutes: 2)); final playing = players.last; for (final size in const [ Size(800, 900), Size(500, 900), Size(1400, 900), ]) { tester.view.physicalSize = size; await tester.pumpAndSettle(); } expect(players.opened, hasLength(1)); expect(players.last, same(playing)); expect(playing!.disposed, isFalse); expect(playing.state.value.position, const Duration(minutes: 2)); }); testWidgets('on a phone the words are below the video', (tester) async { await watch(tester, size: const Size(412, 915)); await playTo(tester, const Duration(seconds: 2)); expect(find.text('友達'), findsOneWidget); expect(tester.takeException(), isNull); }); }