353 lines
12 KiB
Dart
353 lines
12 KiB
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/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<void> watch(
|
||
|
|
WidgetTester tester, {
|
||
|
|
Size size = const Size(1400, 900),
|
||
|
|
bool playerAvailable = true,
|
||
|
|
JellyfinPlayback? playback,
|
||
|
|
Map<(String, int), String>? vtt,
|
||
|
|
List<MediaAudioTrack> 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<void> 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<ListTile>(
|
||
|
|
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('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);
|
||
|
|
});
|
||
|
|
}
|