import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; 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/card/widget/vocabulary_senses.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.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/study/widget/study_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_dictionary_gateway.dart'; import '../../support/fake_jellyfin.dart'; import '../../support/fake_review_gateway.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 [], List? words, FakeReviewGateway? reviews, FakeJellyfinGateway? gateway, String itemId = 'episode-1', }) async { if (gateway != null) { jellyfin = gateway; } 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: words == null ? sampleDictionary() : FakeDictionaryGateway(words: words), reviewGateway: reviews, jellyfinGateway: jellyfin, jellyfinStore: signedInJellyfin(), mediaPlayers: players, ), location: RoutePaths.watch('japanese', itemId), ); 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 the dictionary's checked examples " '(never its line, which may be wrong), tagged with the show and the ' 'episode as tags from Jellyfin, 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'), 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, hasLength(1)); expect(card.content.hints.single.plainText, contains('また会いましょう。')); final tagging = await tags.read(); expect( [for (final tag in tagging.of(card.id)) tag.name], containsAll(['Sample Journey', 'Sample Journey S1 E1', 'JLPT-N5']), ); expect(tagging.named('Sample Journey S1 E1')?.fromJellyfin, isTrue); expect(tagging.named('Sample Journey')?.fromJellyfin, isTrue); // How far it is learnt: not yet. expect(find.text('New'), 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('で'))); // Further down the list. final wait = find.textContaining('待つ', findRichText: true); await tester.scrollUntilVisible( wait, 100, scrollable: find .descendant( of: find.byType(CustomScrollView), matching: find.byType(Scrollable), ) .first, ); await tester.pumpAndSettle(); await tester.tap(wait.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 and the one before play from their start, the ' 'one shown from its start once under way; 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); // Under way: the one shown again, from its start. await playTo(tester, const Duration(seconds: 14)); await tester.tap(find.byTooltip('Previous 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.byTooltip('Settings')); await tester.pumpAndSettle(); 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('Settings')); await tester.pumpAndSettle(); // Tracks are named by their language, plainly. expect(find.text('Japanese'), findsWidgets); 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('the player has play or pause with 10 seconds back and on ' 'beside it, and where it is from 0:00 to its length', (tester) async { await watch(tester); await playTo(tester, const Duration(minutes: 1)); expect(find.text('1:00'), findsOneWidget); expect(find.text('24:00'), findsOneWidget); await tester.tap(find.byTooltip('10 seconds on')); await tester.pumpAndSettle(); expect(players.last!.state.value.position, const Duration(seconds: 70)); await tester.tap(find.byTooltip('10 seconds back')); await tester.tap(find.byTooltip('10 seconds back')); await tester.pumpAndSettle(); expect(players.last!.state.value.position, const Duration(seconds: 50)); }); testWidgets("YouTube's keys work, the lines' too, and ? lists them", ( tester, ) async { await watch(tester); await playTo(tester, const Duration(minutes: 1)); Duration at() => players.last!.state.value.position; await tester.sendKeyEvent(LogicalKeyboardKey.keyL); expect(at(), const Duration(seconds: 70)); await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); expect(at(), const Duration(seconds: 55)); await tester.sendKeyEvent(LogicalKeyboardKey.digit5); expect(at(), const Duration(minutes: 12)); await tester.sendKeyEvent(LogicalKeyboardKey.home); expect(at(), Duration.zero); await playTo(tester, const Duration(seconds: 7)); await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); await tester.pumpAndSettle(); expect(at(), const Duration(seconds: 11)); await tester.sendKeyEvent(LogicalKeyboardKey.keyP); await tester.pumpAndSettle(); await playTo(tester, const Duration(milliseconds: 14900)); expect(players.last!.state.value.playing, isFalse); await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?'); await tester.sendKeyUpEvent(LogicalKeyboardKey.slash); await tester.pumpAndSettle(); expect(find.text('Keyboard shortcuts'), findsOneWidget); expect(find.text('Previous or next line'), findsOneWidget); }); testWidgets('the controls fade out while it plays and the pointer rests, ' 'and come back while paused', (tester) async { await watch(tester); double shown() => tester .widget( find .ancestor( of: find.byTooltip('10 seconds on'), matching: find.byType(AnimatedOpacity), ) .first, ) .opacity; expect(shown(), 1); await tester.tap(find.byTooltip('Play')); await tester.pump(); await tester.pump(const Duration(seconds: 4)); expect(shown(), 0); await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pump(); expect(shown(), 1); }); testWidgets('a word the studyset holds says how far it is learnt, in the ' 'list of the words it holds', (tester) async { final station = sampleWords.firstWhere((word) => word.headword == '駅'); await DictionaryAdder(cards, tags).add('japanese', [station]); final card = (await cards.cardsOf('japanese')).single; final reviews = FakeReviewGateway(); await reviews.insert([ ( id: '', cardId: card.id, rating: 4, reviewedAt: DateTime.now().toUtc().subtract(const Duration(days: 1)), durationMs: 3000, mode: 'review', ), ]); await watch(tester, reviews: reviews); await tester.tap(find.byTooltip('Filter the words')); await tester.pumpAndSettle(); await tester.tap(find.text('Words in the studyset')); await tester.pumpAndSettle(); expect(find.textContaining('駅', findRichText: true), findsOneWidget); expect(find.textContaining('Learned · again'), findsOneWidget); }); testWidgets('the filter narrows the words by their class', (tester) async { await watch(tester); expect(find.textContaining('駅', findRichText: true), findsOneWidget); await tester.tap(find.byTooltip('Filter the words')); await tester.pumpAndSettle(); await tester.tap(find.text('Verbs')); await tester.pumpAndSettle(); expect(find.textContaining('会う', findRichText: true), findsOneWidget); expect(find.textContaining('駅', findRichText: true), findsNothing); }); testWidgets('a word of many senses unfolds over the list under the ' 'pointer, a tap keeping it open; senses one to a line', (tester) async { final long = [ for (final word in sampleWords) if (word.headword == '駅') DictionaryWord( ref: word.ref, headword: word.headword, reading: word.reading, labels: word.labels, senses: [ for (var n = 1; n <= 6; n++) VocabularySense( glosses: ['station, as in sense $n of a long entry'], pos: const ['n'], ), ], ) else word, ]; await watch(tester, words: long); await playTo(tester, const Duration(seconds: 2)); await tester.tap(find.text('駅')); await tester.pumpAndSettle(); expect(find.byType(VocabularySensesView), findsNothing); final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); addTearDown(mouse.removePointer); await mouse.addPointer(location: Offset.zero); await mouse.moveTo( tester.getCenter(find.textContaining('sense 1 of a long').first), ); await tester.pump(const Duration(milliseconds: 700)); await tester.pumpAndSettle(); expect(find.byType(VocabularySensesView), findsOneWidget); expect(find.text('station, as in sense 6 of a long entry'), findsOneWidget); // Left, it folds again. await mouse.moveTo(Offset.zero); await tester.pump(const Duration(milliseconds: 200)); await tester.pumpAndSettle(); expect(find.byType(VocabularySensesView), findsNothing); // Tapped, it stays open with the pointer elsewhere; a tap on it closes // it. await tester.tap(find.textContaining('sense 1 of a long').first); await tester.pumpAndSettle(); await mouse.moveTo(Offset.zero); await tester.pump(const Duration(milliseconds: 200)); await tester.pumpAndSettle(); expect(find.byType(VocabularySensesView), findsOneWidget); await tester.tap(find.byType(VocabularySensesView)); await tester.pumpAndSettle(); expect(find.byType(VocabularySensesView), findsNothing); }); testWidgets('a word tapped also offers the longer words running over it, ' 'to add with the line', (tester) async { await watch( tester, vtt: { ('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nがまだ\n', ('episode-1', 3): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nStill.\n', }, words: [ word('31', 'が', pos: ['prt'], labels: ['common']), word('37', 'がま', pos: ['n'], glosses: ['bulrush']), word('38', 'まだ', pos: ['adv'], labels: ['common']), ], ); await playTo(tester, const Duration(seconds: 2)); await tester.tap(find.text('が')); await tester.pumpAndSettle(); expect(find.text('Longer words here'), findsOneWidget); expect(find.textContaining('bulrush', findRichText: true), findsOneWidget); await tester.tap(find.text('Add').last); await tester.pumpAndSettle(); final card = (await cards.cardsOf('japanese')).single; expect(card.content.entry, (source: 'jmdict', id: '37')); }); testWidgets("the episode's words can be studied or crammed: the cards of " 'its words, held before it was watched too, get its tag', (tester) async { final station = sampleWords.firstWhere((word) => word.headword == '駅'); await DictionaryAdder(cards, tags).add('japanese', [station]); final card = (await cards.cardsOf('japanese')).single; await watch(tester); await tester.tap(find.byTooltip('Play')); await tester.pump(); await tester.tap(find.text('Study')); await tester.pumpAndSettle(); // The words listed it lacks can be added from here too. expect( find.textContaining(RegExp(r'^Add the \d+ new words listed$')), findsOneWidget, ); await tester.tap(find.text('Cram, each once')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); final tagging = await tags.read(); expect( [for (final tag in tagging.of(card.id)) tag.name], contains('Sample Journey S1 E1'), ); expect(players.last!.state.value.playing, isFalse); // The session, over the Watch screen, on the episode's tag, crammed (a // spinner turns there: frames, not settling). await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); final location = GoRouterState.of( tester.element(find.byType(StudyScreen)), ).uri; expect(location.path, '/study/japanese'); expect(location.queryParameters, containsPair('cram', '1')); expect( location.queryParameters, containsPair('tagName', 'Sample Journey S1 E1'), ); }); testWidgets("with none of the video's words held, the button adds the " 'words listed, tagged from Jellyfin, and then studies them', ( tester, ) async { await watch(tester); final add = find.textContaining(RegExp(r'^Add \d+ words$')); expect(add, findsOneWidget); final listed = int.parse( RegExp(r'\d+').firstMatch(tester.widget(add).data!)![0]!, ); expect(listed, greaterThan(1)); await tester.tap(add); await tester.pumpAndSettle(); expect(find.text('$listed words added'), findsOneWidget); final added = await cards.cardsOf('japanese'); expect(added, hasLength(listed)); final tagging = await tags.read(); for (final card in added) { expect( [for (final tag in tagging.of(card.id)) tag.name], containsAll(['Sample Journey', 'Sample Journey S1 E1']), ); } expect(find.text('Study'), findsOneWidget); }); testWidgets("a song's lyrics move with it, its words looked up as a " "video's; a word added is tagged with the artist and the song", ( tester, ) async { await watch(tester, gateway: sampleMusic(), itemId: 'song-1'); expect(find.text('Words in this song'), findsOneWidget); // Every line shows, cut into words; the empty one is left out. expect(find.text('友達'), findsOneWidget); expect(find.text('読'), findsOneWidget); await playTo(tester, const Duration(seconds: 2)); await tester.tap(find.text('駅')); await tester.pumpAndSettle(); await tester.tap(find.text('Add').first); await tester.pumpAndSettle(); final card = (await cards.cardsOf('japanese')).single; final tagging = await tags.read(); expect( [for (final tag in tagging.of(card.id)) tag.name], containsAll(['Sample Artist', 'Sample Artist · Sample Song']), ); expect( tagging.named('Sample Artist · Sample Song')?.fromJellyfin, isTrue, ); }); 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); }); }