The cut takes the longest word and knows no grammar, so 今日は reads as こんにちは where it is 今日 and は. Any stretch of a line's characters can now be chosen by hand: dragged across with a mouse, Shift+clicked from the word or stretch chosen, or on a touch screen held on a character and dragged on. It is looked up as one word, as it stands or as a form of one, in one small request (the instance has the line's text already); while it is chosen the line reads it as that word (きょう over 今日), and what the dictionary lacks as one word is offered as a search on Jisho. The line's words move into line_words.dart, out of the Watch screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
799 lines
29 KiB
Dart
799 lines
29 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/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<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',
|
|
}) 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<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 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<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, 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<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 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'));
|
|
});
|
|
|
|
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 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<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);
|
|
});
|
|
}
|