halkyon/halkyon_learn_flutter/test/feature/jellyfin/watch_screen_test.dart
Tizian 「ティツィアーン」 de6af3efab
feat: a round in the order cards come, shared daily limits, and a start screen
Studying:
- A round is one list in the order its cards come: those done, the one
  shown, those to come. A card due again within the day goes back in
  after 4 more cards when rated Bad, at the end otherwise, so the pills
  above the card follow it; the buttons say "in 4 cards", "at the end",
  "now" (round_order.dart).
- The studied studyset's daily limits hold for everything inside it
  together, as Anki's do for subdecks: twenty topics start 20 new cards
  a day in all, not 20 each.
- The study header's studyset name shows whole on hover or long press.
- The assistant's button sits at the lower left: in the Library across
  from Create studyset, in a studyset above its bar, and higher while
  studying, above the buttons that answer the card.

A studyset:
- Progress also counts the learning paths' steps done and lists the
  PDFs not read to their end, begun first, each opening the reader,
  across its topics (MaterialProgressCubit).
- Next up starts with studying for the exam while one is ahead: its
  reviews due and the new cards its pace leaves for the day.
- Learning paths tagged with a level count towards its automatic exam
  plan and offer it, as cards do.
- A step's first heading equal to its title is dropped on import and
  hidden in paths imported before.

Signing out, or a session the server ended, leads to a start screen
(GitHub, email, without an account, another instance) rather than a new
anonymous account; only the very first start makes one by itself.
Settings names the kind of account and its instance.

Also: the Watch screen reads the cards once for held words and their
progress; its ±10 s buttons tick as the navigation bar does; a map card
keeps its view as its caption changes and brings a picked region
towards the middle; an empty or failed library can be pulled to sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 19:46:34 +02:00

965 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
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 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);
});
}