The Watch screen's test tapped the middle of an unfolded entry's senses, which lay beyond the card's scrolling clip, so the tap reached the list behind it; it now taps the card's first line. The public profile's test tapped the display name's floated label, outside the field, to leave the bio; it now taps the field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
971 lines
35 KiB
Dart
971 lines
35 KiB
Dart
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/device_dictionaries.dart';
|
||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.dart';
|
||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.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/settings/data/settings_repository.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/data/studyset_repository.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:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||
import 'package:material_ui/material_ui.dart';
|
||
|
||
import '../../support/fake_card_gateway.dart';
|
||
import '../../support/fake_device_dictionaries.dart';
|
||
import '../../support/fake_dictionary_gateway.dart';
|
||
import '../../support/fake_jellyfin.dart';
|
||
import '../../support/fake_review_gateway.dart';
|
||
import '../../support/fake_settings_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;
|
||
late FakeStudysetGateway studysets;
|
||
late FakeSettingsGateway settingsGateway;
|
||
|
||
setUp(() {
|
||
studysets = FakeStudysetGateway()
|
||
..receiveRemoteChange(
|
||
'japanese',
|
||
encodeStudysetPayload(
|
||
StudysetContent(
|
||
title: 'Japanese',
|
||
description: RichContent.empty(),
|
||
frontLanguage: 'ja',
|
||
backLanguage: 'en',
|
||
),
|
||
),
|
||
);
|
||
settingsGateway = FakeSettingsGateway();
|
||
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 [],
|
||
List<DictionaryWord>? words,
|
||
FakeReviewGateway? reviews,
|
||
FakeJellyfinGateway? gateway,
|
||
String itemId = 'episode-1',
|
||
FakeDictionaryGateway? dictionary,
|
||
DeviceDictionaries? device,
|
||
}) 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);
|
||
if (!playerAvailable) {
|
||
players = FakeMediaPlayers(available: false);
|
||
}
|
||
await pumpApp(
|
||
tester,
|
||
testDependencies(
|
||
studysetGateway: studysets,
|
||
settingsGateway: settingsGateway,
|
||
cardGateway: cardGateway,
|
||
tagGateway: tagGateway,
|
||
dictionaryGateway:
|
||
dictionary ??
|
||
(words == null
|
||
? sampleDictionary()
|
||
: FakeDictionaryGateway(words: words)),
|
||
deviceDictionaries: device,
|
||
reviewGateway: reviews,
|
||
jellyfinGateway: jellyfin,
|
||
jellyfinStore: signedInJellyfin(),
|
||
mediaPlayers: players,
|
||
),
|
||
location: RoutePaths.watch('japanese', itemId),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
/// Adds words as the screen does, into [studysets].
|
||
DictionaryAdder adder() => DictionaryAdder(
|
||
cards,
|
||
tags,
|
||
StudysetRepository(studysets),
|
||
SettingsRepository(settingsGateway),
|
||
);
|
||
|
||
/// 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('offers once to look words up on the device, saying how much '
|
||
'it downloads; Not now offers it no more', (tester) async {
|
||
final device = deviceDictionariesIn(
|
||
FakeDeviceDictionaryGateway(offered: offeredJapanese()),
|
||
);
|
||
await watch(tester, device: device);
|
||
|
||
expect(find.text('Look words up on this device?'), findsOneWidget);
|
||
expect(find.textContaining('(34.0 MB, more once set up)'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Not now'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Look words up on this device?'), findsNothing);
|
||
expect(device.status.asked, isTrue);
|
||
expect(device.status.progress, isNull);
|
||
});
|
||
|
||
testWidgets('with Japanese’s dictionaries on the device, lines are cut '
|
||
'there, and the instance never sees them', (tester) async {
|
||
final analyser = FakeJapaneseAnalyser(
|
||
cut: (sentence) => [
|
||
SentencePart(text: sentence, start: 0, entries: [sampleWords.first]),
|
||
],
|
||
);
|
||
final dictionary = sampleDictionary();
|
||
await watch(
|
||
tester,
|
||
dictionary: dictionary,
|
||
device: deviceDictionariesIn(
|
||
FakeDeviceDictionaryGateway(offered: offeredJapanese()),
|
||
analyser: analyser,
|
||
initial: DeviceDictionaryStatus(
|
||
installed: installedJapanese(),
|
||
asked: true,
|
||
),
|
||
),
|
||
);
|
||
await playTo(tester, const Duration(seconds: 2));
|
||
|
||
expect(
|
||
analyser.asked.expand((sentences) => sentences),
|
||
contains('昨日、駅で友達に会いました。'),
|
||
);
|
||
expect(dictionary.keysAsked, isEmpty);
|
||
expect(find.text('Look words up on this device?'), 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('a show the server calls otherwise now keeps its tags: a word '
|
||
'added gets those standing for the show and the episode, whatever '
|
||
'they are called', (tester) async {
|
||
final show = await tags.create(
|
||
'旅の記録',
|
||
fromJellyfin: true,
|
||
jellyfinId: const JellyfinTagId(ids: {'tmdb': '424242'}),
|
||
);
|
||
final episode = await tags.create(
|
||
'旅の記録 1-1',
|
||
fromJellyfin: true,
|
||
jellyfinId: const JellyfinTagId(ids: {'tmdb': '424242'}, part: 'S1E1'),
|
||
);
|
||
await watch(tester);
|
||
await players.last!.play();
|
||
await playTo(tester, const Duration(seconds: 2));
|
||
|
||
await tester.tap(find.text('いました'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Add'));
|
||
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.id],
|
||
containsAll([show, episode]),
|
||
);
|
||
expect(tagging.named('Sample Journey'), isNull);
|
||
expect(tagging.named('Sample Journey S1 E1'), isNull);
|
||
});
|
||
|
||
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('で')));
|
||
|
||
// 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, each a short tick where the device vibrates, and where it '
|
||
'is from 0:00 to its length', (tester) async {
|
||
final ticks = <Object?>[];
|
||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||
SystemChannels.platform,
|
||
(call) async {
|
||
if (call.method == 'HapticFeedback.vibrate') {
|
||
ticks.add(call.arguments);
|
||
}
|
||
return null;
|
||
},
|
||
);
|
||
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));
|
||
expect(ticks, List.filled(3, 'HapticFeedbackType.selectionClick'));
|
||
});
|
||
|
||
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<AnimatedOpacity>(
|
||
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 adder().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("watching from a studyset with topics, a topic's word shows "
|
||
'as held, and a new one goes where Adds to says, shown as added at '
|
||
'once', (tester) async {
|
||
studysets.receiveRemoteChange(
|
||
'n3',
|
||
encodeStudysetPayload(StudysetContent.titled('N3')),
|
||
parentId: 'japanese',
|
||
);
|
||
final station = sampleWords.firstWhere((word) => word.headword == '駅');
|
||
await adder().add('n3', [station]);
|
||
await watch(tester);
|
||
|
||
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);
|
||
// The filter's menu stays open for more choices.
|
||
await tester.tap(find.byTooltip('Filter the words'));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Adds to Japanese'));
|
||
await tester.pumpAndSettle();
|
||
// The words' levels say N3 too.
|
||
await tester.tap(find.widgetWithText(MenuItemButton, 'N3'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Adds to N3'), findsOneWidget);
|
||
|
||
await players.last!.play();
|
||
await playTo(tester, const Duration(seconds: 2));
|
||
await tester.tap(find.text('いました'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Add'));
|
||
await tester.pump();
|
||
// Held and new at once, before the card is written.
|
||
expect(find.text('Add'), findsNothing);
|
||
expect(find.text('New'), findsOneWidget);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('会う added'), findsOneWidget);
|
||
expect(
|
||
[for (final card in await cards.cardsOf('n3')) card.content.entry?.id],
|
||
containsAll(['6']),
|
||
);
|
||
expect(await cards.cardsOf('japanese'), isEmpty);
|
||
});
|
||
|
||
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);
|
||
// Its first line: the card scrolls, and its middle may lie beyond it.
|
||
await tester.tap(
|
||
find.descendant(
|
||
of: find.byType(VocabularySensesView),
|
||
matching: find.textContaining('sense 1 of a long'),
|
||
),
|
||
);
|
||
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'));
|
||
});
|
||
|
||
group('a stretch chosen by hand (decided 2026-10-13: by characters)', () {
|
||
final greetings = [
|
||
word('40', '今日', reading: 'きょう', pos: ['n'], glosses: ['today']),
|
||
word('41', '今日は', reading: 'こんにちは', pos: ['int'], glosses: ['hello']),
|
||
word('42', 'は', pos: ['prt'], labels: ['common']),
|
||
word('43', '雨', reading: 'あめ', pos: ['n'], glosses: ['rain']),
|
||
word('44', 'だ', pos: ['cop'], labels: ['common']),
|
||
];
|
||
Future<void> watchGreetings(WidgetTester 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\nA grey sky.\n',
|
||
},
|
||
words: greetings,
|
||
);
|
||
await playTo(tester, const Duration(seconds: 2));
|
||
}
|
||
|
||
/// The middle of the [index]th character of the base text [text] of
|
||
/// the line (four characters to a run at most here, each as wide).
|
||
Offset character(WidgetTester tester, String text, int index) {
|
||
final rect = tester.getRect(find.text(text, findRichText: true).first);
|
||
final width = rect.width / text.length;
|
||
return Offset(rect.left + width * (index + 0.5), rect.center.dy);
|
||
}
|
||
|
||
testWidgets('dragged across with a mouse, the characters chosen are '
|
||
'looked up as one word, also inside a word the cut took longer: '
|
||
'今日 out of 今日は', (tester) async {
|
||
await watchGreetings(tester);
|
||
await players.last!.play();
|
||
expect(find.text('今日', findRichText: true), findsOneWidget);
|
||
|
||
final drag = await tester.startGesture(
|
||
character(tester, '今日', 0),
|
||
kind: PointerDeviceKind.mouse,
|
||
);
|
||
await drag.moveTo(character(tester, '今日', 1));
|
||
await drag.up();
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(players.last!.state.value.playing, isFalse);
|
||
expect(find.textContaining('today', findRichText: true), findsOneWidget);
|
||
expect(find.textContaining('hello', findRichText: true), findsNothing);
|
||
// While chosen, the line reads it as the word chosen: きょう, not
|
||
// こんにち of こんにちは.
|
||
expect(find.text('こんにち'), findsNothing);
|
||
expect(find.text('きょう'), findsWidgets);
|
||
});
|
||
|
||
testWidgets('Shift+click chooses from the word chosen to the character '
|
||
'clicked; what the dictionary lacks as one word is searched on '
|
||
'Jisho', (tester) async {
|
||
await watchGreetings(tester);
|
||
await tester.tap(find.text('雨', findRichText: true));
|
||
await tester.pumpAndSettle();
|
||
expect(find.textContaining('rain', findRichText: true), findsOneWidget);
|
||
|
||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||
final click = await tester.startGesture(
|
||
character(tester, 'だ', 0),
|
||
kind: PointerDeviceKind.mouse,
|
||
);
|
||
await click.up();
|
||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.text("雨だ isn't in the dictionary as one word."),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.text('Search Jisho for it'), findsOneWidget);
|
||
expect(find.textContaining('rain', findRichText: true), findsNothing);
|
||
});
|
||
|
||
testWidgets('on a phone, holding a character starts a stretch, and '
|
||
'moving on extends it', (tester) async {
|
||
await watchGreetings(tester);
|
||
|
||
final hold = await tester.startGesture(character(tester, '今日', 0));
|
||
await tester.pump(const Duration(milliseconds: 600));
|
||
await hold.moveTo(character(tester, '今日', 1));
|
||
await hold.up();
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.textContaining('today', findRichText: true), findsOneWidget);
|
||
});
|
||
});
|
||
|
||
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 adder().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<Text>(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);
|
||
});
|
||
}
|