halkyon/halkyon_learn_flutter/test/feature/jellyfin/watch_screen_test.dart
Tizian 「ティツィアーン」 8fb5e105c9
fix(dictionary): a long kana word stands, and a word offers the longer ones
Cutting a line into words, kana that is no common word gave way to a
shorter common one whatever its length, so もうろく (耄碌, senility) was
taken as もう and ろく. The rule now holds only for kana of up to three
characters (が and まだ still win over がま, a cattail, and だ); a longer
kana word stands even when uncommon, as ichi.moe's segmenter weighs long
kana words by their length. And as no cut is always right, each word now
knows the longer words running over it into its neighbours, worked out
from the same lookup, which the Watch screen's panel offers beneath its
entries ("Longer words here"), each to add with the line.

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

594 lines
21 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/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,
}) async {
if (playback != null || vtt != null) {
jellyfin = sampleJellyfin(
playback: playback ?? sampleEpisodePlayback,
vtt:
vtt ??
{('episode-1', 2): sampleJapanese, ('episode-1', 3): sampleEnglish},
);
}
if (audio.isNotEmpty) {
players = FakeMediaPlayers(audio: audio);
}
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: 'ja',
backLanguage: 'en',
),
),
);
if (!playerAvailable) {
players = FakeMediaPlayers(available: false);
}
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
tagGateway: tagGateway,
dictionaryGateway: words == null
? sampleDictionary()
: FakeDictionaryGateway(words: words),
reviewGateway: reviews,
jellyfinGateway: jellyfin,
jellyfinStore: signedInJellyfin(),
mediaPlayers: players,
),
location: RoutePaths.watch('japanese', 'episode-1'),
);
await tester.pumpAndSettle();
}
/// Plays on to [position].
Future<void> playTo(WidgetTester tester, Duration position) async {
players.last!.moveTo(position);
await tester.pumpAndSettle();
}
testWidgets('the line shown is cut into words, their readings above, its '
'translation beneath; it follows the video', (tester) async {
await watch(tester);
expect(players.opened.single.headers['Authorization'], contains('token-1'));
expect(jellyfin.reports.single.started, isTrue);
await playTo(tester, const Duration(seconds: 2));
expect(find.text('友達'), findsOneWidget);
expect(find.text('ともだち'), findsOneWidget);
// 会いました: 会 read あ, its ending as it stands.
expect(find.text('会'), findsOneWidget);
expect(find.text('あ'), findsOneWidget);
expect(find.text('いました'), findsOneWidget);
expect(find.text('Yesterday I met a friend at the station.'), findsOne);
// Between lines the last one stays.
await playTo(tester, const Duration(milliseconds: 5500));
expect(find.text('友達'), findsOneWidget);
await playTo(tester, const Duration(seconds: 12));
expect(find.text('旅'), findsOneWidget);
expect(find.text('友達'), findsNothing);
});
testWidgets('a word tapped pauses the video and shows what it is a form '
'of; added, it is a flashcard with its line and translation as its '
'first hint, tagged with the show, and is marked from then on', (
tester,
) async {
await watch(tester);
await players.last!.play();
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('いました'));
await tester.pumpAndSettle();
expect(players.last!.state.value.playing, isFalse);
expect(find.text('A form of 会う'), findsOneWidget);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(find.text('会う added, with this line'), findsOneWidget);
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.type, CardType.vocabulary);
expect(card.content.entry, (source: 'jmdict', id: '6'));
expect(
card.content.hints.first.plainText,
allOf(
contains('昨日、駅で友達に会いました。'),
contains('Yesterday I met a friend at the station.'),
),
);
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Journey', 'JLPT-N5']),
);
// 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('で')));
expect(words, contains('待つ'));
await tester.tap(find.textContaining('待つ', findRichText: true).first);
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 16));
expect(find.text('A form of 待つ'), findsOneWidget);
});
testWidgets('the next line 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: 400));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsOneWidget);
expect(find.text('station, as in sense 6 of a long entry'), findsOneWidget);
// Left, it folds again.
await mouse.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsNothing);
// Tapped, it stays open with the pointer elsewhere; a tap on it closes
// it.
await tester.tap(find.textContaining('sense 1 of a long').first);
await tester.pumpAndSettle();
await mouse.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsOneWidget);
await tester.tap(find.byType(VocabularySensesView));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsNothing);
});
testWidgets('a word tapped also offers the longer words running over it, '
'to add with the line', (tester) async {
await watch(
tester,
vtt: {
('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nがまだ\n',
('episode-1', 3): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nStill.\n',
},
words: [
word('31', 'が', pos: ['prt'], labels: ['common']),
word('37', 'がま', pos: ['n'], glosses: ['bulrush']),
word('38', 'まだ', pos: ['adv'], labels: ['common']),
],
);
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('が'));
await tester.pumpAndSettle();
expect(find.text('Longer words here'), findsOneWidget);
expect(find.textContaining('bulrush', findRichText: true), findsOneWidget);
await tester.tap(find.text('Add').last);
await tester.pumpAndSettle();
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.entry, (source: 'jmdict', id: '37'));
});
testWidgets('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);
});
}