feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles

Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.

The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.

A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.

Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-13 11:31:37 +02:00
commit da51ef73cb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 25034 additions and 28 deletions

View file

@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/japanese_deinflection.dart';
/// The words [text] deinflects to that a dictionary entry of [pos] fits.
Set<String> wordsOf(String text, List<String> pos) => {
for (final each in deinflect(text))
if (each.fits(pos)) each.text,
};
void main() {
test('the text as it stands comes first, and fits any word', () {
final first = deinflect('天気').first;
expect(first.text, '天気');
expect(first.steps, isEmpty);
expect(first.form, isNull);
expect(first.fits(const ['n']), isTrue);
});
test('verbs come back to their dictionary form', () {
expect(wordsOf('会いました', ['v5u']), contains('会う'));
expect(wordsOf('読んだ', ['v5m']), contains('読む'));
expect(wordsOf('読んでいない', ['v5m']), contains('読む'));
expect(wordsOf('食べられなかった', ['v1']), contains('食べる'));
expect(wordsOf('出ていった', ['v1']), contains('出る'));
expect(wordsOf('残っている', ['v5r']), contains('残る'));
expect(wordsOf('続けよう', ['v1']), contains('続ける'));
expect(wordsOf('行かなければ', ['v5k-s']), contains('行く'));
expect(wordsOf('なりたい', ['v5r']), contains('なる'));
expect(wordsOf('使える', ['v5u']), contains('使う'));
expect(wordsOf('食べちゃった', ['v1']), contains('食べる'));
expect(wordsOf('読まれた', ['v5m']), contains('読む'));
expect(wordsOf('来ない', ['vk']), contains('来る'));
expect(wordsOf('きました', ['vk']), contains('くる'));
});
test('adjectives and nouns taking する come back too', () {
expect(wordsOf('面白そう', ['adj-i']), contains('面白い'));
expect(wordsOf('早く', ['adj-i']), contains('早い'));
expect(wordsOf('高くなかった', ['adj-i']), contains('高い'));
expect(wordsOf('高すぎた', ['adj-i']), contains('高い'));
expect(wordsOf('勉強した', ['n', 'vs']), contains('勉強'));
});
test('a form only counts for a word of its class', () {
// 読んだ as the past of an ichidan verb 読んる doesn't exist; as the
// past of a godan verb in ぬ or ぶ it would, were there such a word.
expect(wordsOf('読んだ', ['v1']), isNot(contains('読む')));
expect(wordsOf('食べた', ['v5r']), isNot(contains('食べる')));
expect(wordsOf('早く', ['v1']), isNot(contains('早い')));
});
test('stems and forms that only lead on are never offered as words', () {
expect(
deinflect('食べて').map((each) => each.form),
isNot(contains(JapaneseForm.te)),
);
expect(
deinflect('会いました').map((each) => each.form),
isNot(contains(JapaneseForm.masuStem)),
);
});
test('says how it got there, the last step first', () {
final cake = deinflect('食べられなかった').firstWhere(
(each) => each.text == '食べる' && each.form == JapaneseForm.ichidan,
);
expect(cake.steps, ['passive or potential', 'negative', 'past']);
});
}

View file

@ -0,0 +1,178 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import '../../support/fake_dictionary_gateway.dart';
final words = [
word('1', '昨日', reading: 'きのう', pos: ['n', 'adv']),
word('2', '駅', reading: 'えき', pos: ['n']),
word('3', 'で', pos: ['prt']),
word('4', '友達', reading: 'ともだち', pos: ['n']),
word('5', 'に', pos: ['prt']),
word('6', '会う', reading: 'あう', pos: ['v5u', 'vi']),
word('7', '食べる', reading: 'たべる', pos: ['v1', 'vt']),
word('8', 'ケーキ', pos: ['n']),
word('9', '食べ物', reading: 'たべもの', pos: ['n']),
// Kana that is also the stem of a verb no one meant: 会い as ある's?
word('10', 'ある', pos: ['v5r-i']),
];
void main() {
group('sentenceParts', () {
test('cuts a sentence into its words, conjugated ones too, and keeps '
'what is between them as text', () async {
const sentence = '昨日、駅で友達に会いました。';
final repository = DictionaryRepository(
FakeDictionaryGateway(words: words),
);
final parts = (await repository.wordsOf(jmdict, [sentence])).single;
expect(
[for (final part in parts) part.text],
[
'昨日',
'、',
'駅',
'で',
'友達',
'に',
'会いました',
'。',
],
);
final meet = parts[6];
expect(meet.entries.single.headword, '会う');
expect(meet.steps, ['polite past']);
expect(meet.start, sentence.indexOf('会'));
expect(parts[1].isWord, isFalse);
});
test(
'takes the longest word, and a form only for a word of its class',
() async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: words),
);
final [food, cake] = await repository.wordsOf(jmdict, [
'食べ物',
'食べられなかったケーキ',
]);
expect(food.single.entries.single.headword, '食べ物');
expect(cake.first.text, '食べられなかった');
expect(cake.first.entries.single.headword, '食べる');
expect(cake.last.text, 'ケーキ');
},
);
test('reads kana as the words written so, a common one before an '
'uncommon longer one', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(
words: [
word('20', '値', reading: 'ね', pos: ['n'], labels: ['common']),
word('21', 'ね', pos: ['prt'], labels: ['common']),
word('22', 'が', pos: ['prt'], labels: ['common']),
word('23', 'がま', pos: ['n']),
const DictionaryWord(
ref: (source: 'jmdict', id: '24'),
headword: '未だ',
reading: 'まだ',
labels: ['common'],
senses: [
VocabularySense(glosses: ['still'], pos: ['adv'], misc: ['uk']),
],
),
word('25', 'だ', pos: ['cop'], labels: ['common']),
word('26', '値段', reading: 'ねだん', pos: ['n'], labels: ['common']),
],
),
);
final [particle, still, price] = await repository.wordsOf(jmdict, [
'だね',
'がまだ',
'値段',
]);
expect(
[for (final part in particle) part.entries.first.headword],
[
'だ',
'ね',
],
);
expect([for (final part in still) part.text], ['が', 'まだ']);
expect(price.single.entries.single.headword, '値段');
});
test(
'asks once for a sentence, and for several sentences at once',
() async {
final gateway = FakeDictionaryGateway(words: words);
final repository = DictionaryRepository(gateway);
await repository.wordsOf(jmdict, ['駅で', '友達に']);
await repository.wordsOf(jmdict, ['駅で']);
expect(gateway.keysAsked, hasLength(1));
expect(
gateway.keysAsked.single,
lessThan(DictionaryRepository.keysAtOnce),
);
},
);
test('asks for no Latin text, digits or punctuation', () {
final keys = sentenceKeys('OK, 2回!');
expect(keys, contains('回'));
expect(
keys.where((key) => key.contains(RegExp('[A-Za-z0-9,! ]'))),
isEmpty,
);
});
test('a line of subtitles asks for a few thousand keys at most', () {
expect(
sentenceKeys('食べられなかったケーキがまだ残っている。').length,
lessThan(DictionaryRepository.keysAtOnce),
);
});
});
group('furigana', () {
test('reads the kanji of a word as its dictionary form gives them', () {
expect(furigana('食べられなかった', '食べる', 'たべる'), [
(text: '食', reading: 'た'),
(text: 'べられなかった', reading: null),
]);
expect(furigana('取り消す', '取り消す', 'とりけす'), [
(text: '取', reading: 'と'),
(text: 'り', reading: null),
(text: '消', reading: 'け'),
(text: 'す', reading: null),
]);
expect(furigana('友達', '友達', 'ともだち'), [
(text: '友達', reading: 'ともだち'),
]);
});
test("kana and readings that don't fit stay as they are", () {
expect(furigana('ケーキ', 'ケーキ', null), [
(text: 'ケーキ', reading: null),
]);
expect(furigana('たべる', '食べる', 'たべる'), [
(text: 'たべる', reading: null),
]);
expect(furigana('解る', '分かる', 'わかる'), [
(text: '解る', reading: null),
]);
});
});
}

View file

@ -0,0 +1,192 @@
import 'package:flutter_test/flutter_test.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/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:material_ui/material_ui.dart';
import '../../support/fake_jellyfin.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/sample_episode.dart';
import '../../support/test_dependencies.dart';
void main() {
late FakeJellyfinGateway jellyfin;
late MemoryJellyfinStore store;
setUp(() {
jellyfin = sampleJellyfin();
store = MemoryJellyfinStore();
});
Future<void> show(WidgetTester tester, String location) async {
tester.view.physicalSize = const Size(1200, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: 'ja',
backLanguage: 'en',
),
),
);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
dictionaryGateway: sampleDictionary(),
jellyfinGateway: jellyfin,
jellyfinStore: store,
),
location: location,
);
await tester.pumpAndSettle();
}
/// Finds the server, from Settings or the studyset.
Future<void> findServer(WidgetTester tester) async {
await tester.enterText(
find.widgetWithText(TextField, 'Server address'),
'jellyfin.local:8920',
);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
}
testWidgets('Settings connects a server over HTTPS with a name and '
'password, kept on the device, and signs out of it', (tester) async {
await show(tester, RoutePaths.settings);
await tester.scrollUntilVisible(
find.widgetWithText(TextField, 'Server address'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.enterText(
find.widgetWithText(TextField, 'Server address'),
'nothing.local',
);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(
find.text('No server answered at that address over HTTPS.'),
findsOneWidget,
);
await tester.enterText(
find.widgetWithText(TextField, 'Server address'),
'http://jellyfin.local:8920',
);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(
find.text(
"That address isn't encrypted, and your password would travel in "
"the clear. Use the server's HTTPS address.",
),
findsOneWidget,
);
await findServer(tester);
expect(find.text('Living room'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, 'User name'), 'mika');
await tester.enterText(find.widgetWithText(TextField, 'Password'), 'wrong');
await tester.tap(find.text('Sign in'));
await tester.pumpAndSettle();
expect(find.text('The user name or password is wrong.'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Password'),
'secret',
);
await tester.tap(find.text('Sign in'));
await tester.pumpAndSettle();
expect(find.text('mika on Living room'), findsOneWidget);
expect(store.kept?.account?.token, 'token-1');
await tester.tap(find.byTooltip('Sign out of Jellyfin'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, 'Server address'), findsOneWidget);
expect(jellyfin.signedOut, ['token-1']);
});
testWidgets('a code approved in another Jellyfin app signs in', (
tester,
) async {
await show(tester, RoutePaths.jellyfin('japanese'));
await findServer(tester);
await tester.tap(find.text('Sign in with a code'));
// It waits, spinning, until the code is approved.
await tester.pump();
await tester.pump();
expect(find.text('123456'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
expect(find.text('123456'), findsOneWidget);
jellyfin.approved = true;
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('Anime'), findsOneWidget);
expect(store.kept?.account?.token, 'token-2');
});
testWidgets('a show tied to the studyset comes first next time; an '
'episode opens to be watched', (tester) async {
store = signedInJellyfin();
await show(tester, RoutePaths.jellyfin('japanese'));
expect(find.text('Tied to this studyset'), findsNothing);
await tester.tap(find.text('Anime'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Tie to this studyset'));
await tester.pumpAndSettle();
expect(find.byTooltip('Untie from this studyset'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Tied to this studyset'), findsOneWidget);
await tester.tap(find.text('Sample Journey'));
await tester.pumpAndSettle();
await tester.tap(find.text('Season 1'));
await tester.pumpAndSettle();
expect(find.text('S1 E1 · 24 min'), findsOneWidget);
await tester.tap(find.text('The Station'));
await tester.pumpAndSettle();
expect(find.byType(WatchScreen), findsOneWidget);
});
testWidgets('a search finds shows and episodes by their name', (
tester,
) async {
store = signedInJellyfin();
await show(tester, RoutePaths.jellyfin('japanese'));
await tester.enterText(find.byType(TextField), 'station');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.text('The Station'), findsOneWidget);
expect(find.text('Libraries'), findsNothing);
});
testWidgets("the studyset's Add material offers watching from Jellyfin", (
tester,
) async {
await show(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tester.tap(find.text('Watch from Jellyfin'));
await tester.pump();
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, 'Server address'), findsOneWidget);
});
}

View file

@ -0,0 +1,589 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import '../../support/fake_jellyfin.dart';
import '../../support/sample_episode.dart';
/// A server answering as Jellyfin v12.2.0 does (its answers recorded),
/// noting each request.
MockClient jellyfin12(List<http.Request> requests) => MockClient((
request,
) async {
requests.add(request);
http.Response json(Object? body, [int status = 200]) => http.Response.bytes(
utf8.encode(jsonEncode(body)),
status,
headers: {
'content-type': 'application/json; charset=utf-8',
},
);
final token = request.headers['Authorization'] ?? '';
final signedIn = token.contains('Token="t-1"');
return switch ((request.method, request.url.path)) {
// v12 writes this one in camelCase, the rest in PascalCase.
('GET', '/System/Info/Public') => json({
'localAddress': 'http://172.17.0.2:8096',
'serverName': 'Living room',
'version': '12.2.0',
'productName': 'Jellyfin Server',
'id': 'e39b17f2',
'startupWizardCompleted': true,
}),
('POST', '/Users/AuthenticateByName') =>
(jsonDecode(request.body) as Map<String, Object?>)['Pw'] == 'secret'
? json({
'User': {'Name': 'mika', 'Id': 'u-1', 'ServerId': 'e39b17f2'},
'AccessToken': 't-1',
'ServerId': 'e39b17f2',
})
: json({'error': 'Invalid user or password'}, 401),
_ when !signedIn => json(null, 401),
('GET', '/UserViews') => json({
'Items': [
{
'Name': 'Anime',
'Id': 'lib-1',
'Type': 'CollectionFolder',
'CollectionType': 'tvshows',
'ImageTags': <String, String>{},
},
{'Name': 'Music', 'Id': 'lib-2', 'Type': 'MusicAlbum'},
],
'TotalRecordCount': 2,
}),
('GET', '/Items/ep-1') => json({
'Name': 'Sample Journey S01E01',
'Id': 'ep-1',
'Type': 'Episode',
'SeriesName': 'Sample Journey',
'IndexNumber': 1,
'ParentIndexNumber': 1,
'RunTimeTicks': 520230000,
'ImageTags': {'Primary': '898f'},
'UserData': {'PlaybackPositionTicks': 260115000, 'Played': false},
}),
('POST', '/Items/ep-1/PlaybackInfo') => json({
'MediaSources': [
{
'Id': 'ep-1',
'Container': 'mkv',
'SupportsDirectPlay': true,
'RunTimeTicks': 520230000,
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
{
'Index': 1,
'Type': 'Audio',
'Language': 'ger',
'Codec': 'aac',
'IsDefault': true,
'DisplayTitle': 'German - AAC - Stereo - Default',
},
{
'index': 4,
'type': 'Audio',
'language': 'jpn',
'codec': 'aac',
'isDefault': true,
'title': 'Japanese [Original]',
},
{
'Index': 2,
'Type': 'Subtitle',
'Language': 'jpn',
'Codec': 'ass',
'IsTextSubtitleStream': true,
'IsDefault': true,
'DisplayTitle': '日本語 - Japanese - Default - ASS',
},
{
'Index': 3,
'Type': 'Subtitle',
'Language': 'eng',
'Codec': 'PGSSUB',
'IsTextSubtitleStream': false,
'DisplayTitle': 'English - PGSSUB',
},
{
'Index': 5,
'Type': 'Subtitle',
'Language': 'jpn',
'Codec': 'subrip',
'IsTextSubtitleStream': true,
'IsForced': true,
'DisplayTitle': '日本語 - Japanese - Forced - SUBRIP',
},
{
'Index': 6,
'Type': 'Subtitle',
'Language': 'jpn',
'Codec': 'subrip',
'IsTextSubtitleStream': true,
'Title': 'closedcaptions',
'DisplayTitle': 'closedcaptions - 日本語 - SUBRIP',
},
{
'Index': 7,
'Type': 'Subtitle',
'Language': 'eng',
'Codec': 'subrip',
'IsTextSubtitleStream': true,
'IsHearingImpaired': true,
'DisplayTitle': 'English - SDH - SUBRIP',
},
],
},
],
'PlaySessionId': 'ps-1',
}),
('GET', '/Videos/ep-1/ep-1/Subtitles/2/0/Stream.vtt') =>
http.Response.bytes(
utf8.encode('WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は\n'),
200,
),
('POST', '/Sessions/Playing/Stopped') => json(null, 204),
_ => json(null, 404),
};
});
void main() {
group('HttpJellyfinGateway', () {
late List<http.Request> requests;
late HttpJellyfinGateway gateway;
setUp(() {
requests = [];
gateway = HttpJellyfinGateway(
jellyfin12(requests),
device: (id: 'device-1', name: 'Linux', version: '0.13.0'),
);
});
Future<JellyfinAccount> signIn() async => await gateway.signIn(
await gateway.server(Uri.parse('http://jellyfin.local:8096')),
'mika',
'secret',
);
test(
'finds a server and signs in, naming the app and the device',
() async {
final server = await gateway.server(
Uri.parse('http://jellyfin.local:8096'),
);
expect(server.name, 'Living room');
expect(server.version, '12.2.0');
final account = await gateway.signIn(server, 'mika', 'secret');
expect(account.userId, 'u-1');
expect(account.token, 't-1');
expect(
requests.last.headers['Authorization'],
'MediaBrowser Client="Halkyon Learn", Device="Linux", '
'DeviceId="device-1", Version="0.13.0"',
);
await expectLater(
gateway.signIn(server, 'mika', 'wrong'),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.wrongCredentials,
),
),
);
},
);
test('lists libraries, leaving out what is no video', () async {
final account = await signIn();
final libraries = await gateway.libraries(account);
expect(libraries, [
const JellyfinItem(
id: 'lib-1',
name: 'Anime',
kind: JellyfinItemKind.library,
),
]);
expect(requests.last.headers['Authorization'], contains('Token="t-1"'));
expect(requests.last.url.queryParameters['userId'], 'u-1');
});
test('a playback has its subtitle and audio tracks, which are forced '
'and which for the hard of hearing, and where to resume; the stream '
'is the file as stored; subtitles come as WebVTT', () async {
final account = await signIn();
final playback = await gateway.playback(account, 'ep-1');
expect(playback.playSessionId, 'ps-1');
expect(playback.resumeAt.inSeconds, 26);
expect(playback.subtitles, [
const JellyfinSubtitleTrack(
index: 2,
title: '日本語 - Japanese - Default - ASS',
language: 'jpn',
isDefault: true,
),
const JellyfinSubtitleTrack(
index: 3,
title: 'English - PGSSUB',
language: 'eng',
isText: false,
),
const JellyfinSubtitleTrack(
index: 5,
title: '日本語 - Japanese - Forced - SUBRIP',
language: 'jpn',
isForced: true,
),
// Captions for the hard of hearing are often marked by their name
// alone.
const JellyfinSubtitleTrack(
index: 6,
title: 'closedcaptions - 日本語 - SUBRIP',
language: 'jpn',
isHearingImpaired: true,
),
const JellyfinSubtitleTrack(
index: 7,
title: 'English - SDH - SUBRIP',
language: 'eng',
isHearingImpaired: true,
),
]);
expect(playback.audio, [
const JellyfinAudioTrack(
index: 1,
title: 'German - AAC - Stereo - Default',
language: 'ger',
isDefault: true,
),
const JellyfinAudioTrack(
index: 4,
title: 'Japanese [Original]',
language: 'jpn',
isDefault: true,
),
]);
final stream = gateway.streamOf(account, playback);
expect(stream.path, '/Videos/ep-1/stream');
expect(stream.queryParameters, containsPair('static', 'true'));
expect(
await gateway.subtitles(account, playback, playback.subtitles.first),
contains('今日は'),
);
});
test('a token the server no longer takes is signed out; nothing '
'answering is unreachable', () async {
final server = await gateway.server(
Uri.parse('http://jellyfin.local:8096'),
);
final stale = JellyfinAccount(
server: server,
userId: 'u-1',
userName: 'mika',
token: 'revoked',
);
await expectLater(
gateway.libraries(stale),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.signedOut,
),
),
);
final offline = HttpJellyfinGateway(
MockClient((_) => throw const SocketException('down')),
device: (id: 'd', name: 'n', version: 'v'),
);
await expectLater(
offline.server(Uri.parse('http://jellyfin.local:8096')),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.unreachable,
),
),
);
});
test('reports where a video stopped, in ticks', () async {
final account = await signIn();
final playback = await gateway.playback(account, 'ep-1');
await gateway.report(
account,
playback,
const Duration(seconds: 21),
stopped: true,
);
final body = jsonDecode(requests.last.body) as Map<String, Object?>;
expect(requests.last.url.path, '/Sessions/Playing/Stopped');
expect(body['PositionTicks'], 210000000);
expect(body['PlaySessionId'], 'ps-1');
});
});
group('JellyfinRepository', () {
test('an address is reached over HTTPS only, plain HTTP refused '
'before anything is sent', () async {
final asked = <Uri>[];
final repository = JellyfinRepository(
(_) => _Asking(asked),
MemoryJellyfinStore(),
deviceName: 'Test',
appVersion: '1',
);
final server = await repository.find('jellyfin.example:8920');
expect(server.address, Uri.parse('https://jellyfin.example:8920'));
expect(asked, [Uri.parse('https://jellyfin.example:8920')]);
asked.clear();
await expectLater(
repository.find('http://jellyfin.local:8096'),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.notEncrypted,
),
),
);
await expectLater(
repository.find('httpsonly.local:8096'),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.unreachable,
),
),
);
expect(asked, [Uri.parse('https://httpsonly.local:8096')]);
});
test('a server on this machine may answer over plain HTTP: nothing '
'leaves it', () async {
final asked = <Uri>[];
final repository = JellyfinRepository(
(_) => _Asking(asked),
MemoryJellyfinStore(),
deviceName: 'Test',
appVersion: '1',
);
expect(
(await repository.find('localhost:8096')).address,
Uri.parse('http://localhost:8096'),
);
expect(asked, [
Uri.parse('https://localhost:8096'),
Uri.parse('http://localhost:8096'),
]);
expect(
(await repository.find('http://127.0.0.1:8096')).address,
Uri.parse('http://127.0.0.1:8096'),
);
expect(
(await repository.find('http://[::1]:8096')).address.host,
'::1',
);
});
test('signing in is kept on the device; signing out forgets it and '
'ends the session on the server', () async {
final store = MemoryJellyfinStore();
final gateway = FakeJellyfinGateway();
final repository = JellyfinRepository(
(_) => gateway,
store,
deviceName: 'Test',
appVersion: '1',
);
await repository.signIn(fakeJellyfinServer, 'mika', 'secret');
expect(store.kept?.account?.token, 'token-1');
await repository.signOut();
expect(store.kept?.account, isNull);
expect(gateway.signedOut, ['token-1']);
});
test('Quick Connect signs in once the code is approved', () async {
final gateway = FakeJellyfinGateway();
final repository = JellyfinRepository(
(_) => gateway,
MemoryJellyfinStore(),
deviceName: 'Test',
appVersion: '1',
);
final code = await repository.startQuickConnect(fakeJellyfinServer);
expect(
await repository.finishQuickConnect(fakeJellyfinServer, code.secret),
isFalse,
);
gateway.approved = true;
expect(
await repository.finishQuickConnect(fakeJellyfinServer, code.secret),
isTrue,
);
expect(repository.account?.token, 'token-2');
});
test('a token the server refuses signs the device out', () async {
final gateway = sampleJellyfin()..fail = JellyfinProblem.signedOut;
final repository = JellyfinRepository(
(_) => gateway,
signedInJellyfin(),
deviceName: 'Test',
appVersion: '1',
);
await expectLater(
repository.libraries(),
throwsA(isA<JellyfinException>()),
);
expect(repository.account, isNull);
});
});
test('what the device keeps comes back as it was kept', () {
final state = JellyfinState(
deviceId: 'device-1',
account: JellyfinAccount(
server: fakeJellyfinServer,
userId: 'user-1',
userName: 'mika',
token: 'token-1',
),
);
expect(
jellyfinStateFromJson(jsonDecode(jsonEncode(jellyfinStateToJson(state)))),
state,
);
expect(jellyfinStateFromJson('not a state').deviceId, hasLength(32));
});
test('a line of two is one: Japanese joined as it is, others with a '
'space', () {
expect(oneLine('昨日、駅で\n友達に会いました。'), '昨日、駅で友達に会いました。');
expect(oneLine('Yesterday I met\na friend.'), 'Yesterday I met a friend.');
});
group('the tracks in a language', () {
const forced = JellyfinSubtitleTrack(
index: 1,
title: 'Japanese (forced)',
language: 'jpn',
isDefault: true,
isForced: true,
);
const captions = JellyfinSubtitleTrack(
index: 2,
title: 'Japanese (SDH)',
language: 'jpn',
isHearingImpaired: true,
);
const full = JellyfinSubtitleTrack(
index: 3,
title: 'Japanese',
language: 'jpn',
);
const english = JellyfinSubtitleTrack(
index: 4,
title: 'English',
language: 'eng',
isDefault: true,
);
test('the subtitles read are whole: never a forced track, which only '
"covers what the sound doesn't say; captions for the hard of "
'hearing only without plain ones', () {
expect(subtitleTrackIn([forced, captions, full, english], 'ja'), full);
expect(subtitleTrackIn([forced, captions, english], 'ja'), captions);
expect(subtitleTrackIn([forced, english], 'ja'), isNull);
expect(subtitleTrackIn([forced, captions, full, english], 'en'), english);
expect(subtitleTrackIn([full], null), isNull);
});
test('the sound is in the language, whichever track the file marks '
'default; a commentary or a description only without another', () {
const german = JellyfinAudioTrack(
index: 1,
title: 'German',
language: 'ger',
isDefault: true,
);
const commentary = JellyfinAudioTrack(
index: 2,
title: "Director's commentary",
language: 'jpn',
isDefault: true,
);
const japanese = JellyfinAudioTrack(
index: 3,
title: 'Japanese [Original]',
language: 'jpn',
);
expect(audioTrackIn([german, commentary, japanese], 'ja'), japanese);
expect(audioTrackIn([german, commentary], 'ja'), commentary);
expect(audioTrackIn([german], 'ja'), isNull);
expect(audioTrackIn([german, japanese], null), isNull);
});
});
test("a studyset's language finds Jellyfin's", () {
expect(jellyfinLanguagesOf('ja'), {'jpn'});
expect(jellyfinLanguagesOf('de-AT'), {'ger', 'deu'});
expect(jellyfinLanguagesOf('tlh'), {'tlh'});
});
test("Jellyfin's language finds every name a player may give it", () {
expect(languageNamesOf('jpn'), {'jpn', 'ja'});
expect(languageNamesOf('ger'), {'ger', 'deu', 'de'});
expect(languageNamesOf('tlh'), {'tlh'});
expect(languageNamesOf(null), isEmpty);
});
}
/// A gateway noting the addresses tried, finding a server at
/// `jellyfin.example` over HTTPS and on this machine over plain HTTP.
class _Asking(final List<Uri> _asked) extends FakeJellyfinGateway {
@override
Future<JellyfinServer> server(Uri address) async {
_asked.add(address);
final found = address.scheme == 'https'
? address.host == 'jellyfin.example'
: const {'localhost', '127.0.0.1', '::1'}.contains(address.host);
if (!found) {
throw JellyfinException(problem: JellyfinProblem.unreachable);
}
return JellyfinServer(
address: address,
id: fakeJellyfinServer.id,
name: fakeJellyfinServer.name,
version: fakeJellyfinServer.version,
);
}
}

View file

@ -0,0 +1,145 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/jellyfin_ties_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/sample_episode.dart';
void main() {
const elsewhere = JellyfinTie(serverId: 'server-2', itemId: 'show-9');
late FakeSettingsGateway settingsGateway;
late SettingsRepository settings;
late JellyfinRepository jellyfin;
late JellyfinTiesCubit cubit;
setUp(() async {
settingsGateway = FakeSettingsGateway();
settings = SettingsRepository(settingsGateway);
jellyfin = JellyfinRepository(
(_) => sampleJellyfin(),
signedInJellyfin(),
deviceName: 'Test',
appVersion: '1',
);
await jellyfin.open();
});
tearDown(() async {
await cubit.close();
await settings.dispose();
});
/// The cubit's state once it settled.
Future<JellyfinTiesState> settled() async {
await pumpEventQueue();
return cubit.state;
}
test("a studyset's ties are the signed-in server's, named as the server "
'names them now, each tied once', () async {
await settings.update(
(settings) => settings.withJellyfinTie('japanese', elsewhere),
);
cubit = JellyfinTiesCubit(settings, jellyfin, 'japanese');
expect((await settled()).items, isEmpty);
await cubit.tie(sampleShow);
await cubit.tie(sampleShow);
final state = await settled();
expect(state.items, [sampleShow]);
expect(state.isTied('show-1'), isTrue);
expect((await settings.read()).jellyfinTiesOf('japanese'), [
elsewhere,
const JellyfinTie(serverId: 'server-1', itemId: 'show-1'),
]);
});
test('the instance gets ids only, never a title', () async {
cubit = JellyfinTiesCubit(settings, jellyfin, 'japanese');
await cubit.tie(sampleShow);
await settled();
final (_, payload) = settingsGateway.writes.last;
expect(
utf8.decode(payload.buffer.asUint8List()),
isNot(contains('Sample')),
);
expect(
decodePayload(payload, newestSchema: settingsSchemaVersion),
containsPair('jellyfinTies', {
'japanese': [
{'server': 'server-1', 'item': 'show-1'},
],
}),
);
});
test("a tie made on another device shows, named by this device's "
'server', () async {
cubit = JellyfinTiesCubit(settings, jellyfin, 'japanese');
expect((await settled()).items, isEmpty);
settingsGateway.receiveRemoteRow(
'settings-remote',
encodeSettingsPayload(
const PersonSettings().withJellyfinTie(
'japanese',
const JellyfinTie(serverId: 'server-1', itemId: 'show-1'),
),
),
);
expect((await settled()).items, [sampleShow]);
});
test("untying keeps the other servers' ties; an item the server no "
'longer has is left out', () async {
await settings.update(
(settings) => settings
.withJellyfinTie('japanese', elsewhere)
.withJellyfinTie(
'japanese',
const JellyfinTie(serverId: 'server-1', itemId: 'gone'),
)
.withJellyfinTie(
'japanese',
const JellyfinTie(serverId: 'server-1', itemId: 'show-1'),
),
);
cubit = JellyfinTiesCubit(settings, jellyfin, 'japanese');
expect((await settled()).items, [sampleShow]);
await cubit.untie('show-1');
expect((await settled()).items, isEmpty);
expect((await settings.read()).jellyfinTiesOf('japanese'), [
elsewhere,
const JellyfinTie(serverId: 'server-1', itemId: 'gone'),
]);
});
test('signed out, nothing is tied', () async {
await settings.update(
(settings) => settings.withJellyfinTie(
'japanese',
const JellyfinTie(serverId: 'server-1', itemId: 'show-1'),
),
);
cubit = JellyfinTiesCubit(settings, jellyfin, 'japanese');
expect((await settled()).items, [sampleShow]);
await jellyfin.signOut();
final state = await settled();
expect(state.items, isEmpty);
expect(state.isTied('show-1'), isFalse);
});
}

View file

@ -0,0 +1,353 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_jellyfin.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/fake_tag_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/sample_episode.dart';
import '../../support/test_dependencies.dart';
void main() {
late FakeCardGateway cardGateway;
late FakeTagGateway tagGateway;
late CardRepository cards;
late TagRepository tags;
late FakeJellyfinGateway jellyfin;
late FakeMediaPlayers players;
setUp(() {
cardGateway = FakeCardGateway();
tagGateway = FakeTagGateway();
cards = CardRepository(cardGateway);
tags = TagRepository(tagGateway);
jellyfin = sampleJellyfin();
players = FakeMediaPlayers();
});
tearDown(() async {
await cards.dispose();
await tags.dispose();
});
/// Opens the sample episode from the Japanese studyset, in a window of
/// [size].
Future<void> watch(
WidgetTester tester, {
Size size = const Size(1400, 900),
bool playerAvailable = true,
JellyfinPlayback? playback,
Map<(String, int), String>? vtt,
List<MediaAudioTrack> audio = const [],
}) async {
if (playback != null || vtt != null) {
jellyfin = sampleJellyfin(
playback: playback ?? sampleEpisodePlayback,
vtt:
vtt ??
{('episode-1', 2): sampleJapanese, ('episode-1', 3): sampleEnglish},
);
}
if (audio.isNotEmpty) {
players = FakeMediaPlayers(audio: audio);
}
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: 'ja',
backLanguage: 'en',
),
),
);
if (!playerAvailable) {
players = FakeMediaPlayers(available: false);
}
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
tagGateway: tagGateway,
dictionaryGateway: sampleDictionary(),
jellyfinGateway: jellyfin,
jellyfinStore: signedInJellyfin(),
mediaPlayers: players,
),
location: RoutePaths.watch('japanese', 'episode-1'),
);
await tester.pumpAndSettle();
}
/// Plays on to [position].
Future<void> playTo(WidgetTester tester, Duration position) async {
players.last!.moveTo(position);
await tester.pumpAndSettle();
}
testWidgets('the line shown is cut into words, their readings above, its '
'translation beneath; it follows the video', (tester) async {
await watch(tester);
expect(players.opened.single.headers['Authorization'], contains('token-1'));
expect(jellyfin.reports.single.started, isTrue);
await playTo(tester, const Duration(seconds: 2));
expect(find.text('友達'), findsOneWidget);
expect(find.text('ともだち'), findsOneWidget);
// 会いました: 会 read あ, its ending as it stands.
expect(find.text('会'), findsOneWidget);
expect(find.text('あ'), findsOneWidget);
expect(find.text('いました'), findsOneWidget);
expect(find.text('Yesterday I met a friend at the station.'), findsOne);
// Between lines the last one stays.
await playTo(tester, const Duration(milliseconds: 5500));
expect(find.text('友達'), findsOneWidget);
await playTo(tester, const Duration(seconds: 12));
expect(find.text('旅'), findsOneWidget);
expect(find.text('友達'), findsNothing);
});
testWidgets('a word tapped pauses the video and shows what it is a form '
'of; added, it is a flashcard with its line and translation as its '
'first hint, tagged with the show, and is marked from then on', (
tester,
) async {
await watch(tester);
await players.last!.play();
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('いました'));
await tester.pumpAndSettle();
expect(players.last!.state.value.playing, isFalse);
expect(find.text('A form of 会う'), findsOneWidget);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(find.text('会う added, with this line'), findsOneWidget);
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.type, CardType.vocabulary);
expect(card.content.entry, (source: 'jmdict', id: '6'));
expect(
card.content.hints.first.plainText,
allOf(
contains('昨日、駅で友達に会いました。'),
contains('Yesterday I met a friend at the station.'),
),
);
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Journey', 'JLPT-N5']),
);
expect(find.byTooltip('In this studyset'), findsOneWidget);
});
testWidgets("the video's words are listed, the most frequent first, "
'particles left out; one opens its first line', (tester) async {
await watch(tester);
expect(find.text('Words in this video'), findsOneWidget);
final words = [
for (final tile in tester.widgetList<ListTile>(
find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(ListTile),
),
))
(tile.title! as Text).textSpan!.toPlainText().split(' ').first,
];
// 駅 and 友達 come twice; で, に, を, けど and だ are grammar.
expect(words.take(2).toSet(), {'駅', '友達'});
expect(words, isNot(contains('で')));
expect(words, contains('待つ'));
await tester.tap(find.textContaining('待つ', findRichText: true).first);
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 16));
expect(find.text('A form of 待つ'), findsOneWidget);
});
testWidgets('the next line, the one before and the same again play from '
'their start; a line can end with a pause', (tester) async {
await watch(tester);
await playTo(tester, const Duration(seconds: 7));
await tester.tap(find.byTooltip('Next line'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 11));
expect(players.last!.state.value.playing, isTrue);
await tester.tap(find.byTooltip('Repeat line'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 11));
await tester.tap(find.byTooltip('Previous line'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 6));
await tester.tap(find.text('Pause after each line'));
await tester.pumpAndSettle();
await playTo(tester, const Duration(milliseconds: 9900));
expect(players.last!.state.value.playing, isFalse);
});
testWidgets('leaving tells the server where the video stopped', (
tester,
) async {
await watch(tester);
await playTo(tester, const Duration(seconds: 12));
GoRouter.of(tester.element(find.byType(WatchScreen))).go(RoutePaths.home);
await tester.pumpAndSettle();
expect(jellyfin.reports.last.stopped, isTrue);
expect(jellyfin.reports.last.position, const Duration(seconds: 12));
expect(players.last!.disposed, isTrue);
});
testWidgets('without a player the subtitles still show, and the screen '
'says why the video does not', (tester) async {
await watch(tester, playerAvailable: false);
expect(find.textContaining("Videos can't play on this device"), findsOne);
expect(find.text('昨日'), findsNothing);
expect(find.text('Words in this video'), findsOneWidget);
});
testWidgets("the sound is in the studyset's language, whichever track the "
'file plays first; the menu switches it', (tester) async {
await watch(
tester,
playback: const JellyfinPlayback(
itemId: 'episode-1',
mediaSourceId: 'episode-1',
playSessionId: 'session-1',
runTime: Duration(minutes: 24),
subtitles: [
JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'),
JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'),
],
audio: [
JellyfinAudioTrack(
index: 4,
title: 'German',
language: 'ger',
isDefault: true,
),
JellyfinAudioTrack(
index: 5,
title: 'English',
language: 'eng',
isDefault: true,
),
JellyfinAudioTrack(
index: 6,
title: 'Japanese [Original]',
language: 'jpn',
isDefault: true,
),
],
),
// As Android names them.
audio: const [
MediaAudioTrack(id: 'a', language: 'de'),
MediaAudioTrack(id: 'b', language: 'en'),
MediaAudioTrack(id: 'c', language: 'ja'),
],
);
expect(players.last!.selectedAudio?.id, 'c');
await tester.tap(find.byTooltip('Audio and subtitles'));
await tester.pumpAndSettle();
await tester.tap(find.text('Audio'));
await tester.pumpAndSettle();
await tester.tap(find.text('German'));
await tester.pump();
await tester.pumpAndSettle();
expect(players.last!.selectedAudio?.id, 'a');
});
testWidgets("a forced track, which only covers what the sound doesn't "
'say, is never the one read', (tester) async {
await watch(
tester,
playback: const JellyfinPlayback(
itemId: 'episode-1',
mediaSourceId: 'episode-1',
playSessionId: 'session-1',
runTime: Duration(minutes: 24),
subtitles: [
JellyfinSubtitleTrack(
index: 7,
title: 'Japanese (forced)',
language: 'jpn',
isDefault: true,
isForced: true,
),
JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'),
JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'),
],
),
);
await playTo(tester, const Duration(seconds: 2));
expect(find.text('友達'), findsOneWidget);
});
testWidgets('a line takes the whole width, its translation on one line '
'too; the video keeps its size however long the line is', (
tester,
) async {
final long = List.filled(8, '昨日、駅で友達に会いました。').join();
await watch(
tester,
vtt: {
('episode-1', 2):
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n旅\n\n'
'00:00:06.000 --> 00:00:10.000\n$long\n',
('episode-1', 3):
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA journey.\n\n'
'00:00:06.000 --> 00:00:10.000\n'
'So I say, "Here you go!",\nand I lent it to him.\n',
},
);
await playTo(tester, const Duration(seconds: 2));
final controls = tester.getTopLeft(find.byTooltip('Previous line'));
await playTo(tester, const Duration(seconds: 7));
expect(
find.text('So I say, "Here you go!", and I lent it to him.'),
findsOneWidget,
);
expect(tester.getTopLeft(find.byTooltip('Previous line')), controls);
expect(tester.takeException(), isNull);
});
testWidgets('on a phone the words are below the video', (tester) async {
await watch(tester, size: const Size(412, 915));
await playTo(tester, const Duration(seconds: 2));
expect(find.text('友達'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
@ -280,7 +281,7 @@ void main() {
'schema; a payload from before has it off', () {
const counted = PersonSettings(countLearning: true);
expect(settingsSchemaVersion, 8);
expect(settingsSchemaVersion, greaterThanOrEqualTo(8));
expect(decodeSettingsPayload(encodeSettingsPayload(counted)), counted);
expect(
decodeSettingsPayload(
@ -289,4 +290,104 @@ void main() {
isFalse,
);
});
group('the Jellyfin shows tied to a studyset', () {
const show = JellyfinTie(serverId: 'server-1', itemId: 'show-1');
const film = JellyfinTie(serverId: 'server-1', itemId: 'film-1');
const elsewhere = JellyfinTie(serverId: 'server-2', itemId: 'show-9');
test('are tied once each, in the order tied, and untied', () {
final settings = const PersonSettings()
.withJellyfinTie('japanese', show)
.withJellyfinTie('japanese', film)
.withJellyfinTie('japanese', show);
expect(settings.jellyfinTiesOf('japanese'), [show, film]);
expect(
settings
.withoutJellyfinTie('japanese', show)
.jellyfinTiesOf('japanese'),
[film],
);
expect(
settings
.withoutJellyfinTie('japanese', show)
.withoutJellyfinTie('japanese', film)
.jellyfinTies,
isEmpty,
);
});
test('survive a round trip as ids only, in the ninth schema', () {
final settings = const PersonSettings()
.withJellyfinTie('japanese', show)
.withJellyfinTie('japanese', elsewhere);
final payload = decodePayload(
encodeSettingsPayload(settings),
newestSchema: settingsSchemaVersion,
);
expect(settingsSchemaVersion, 9);
expect(payload['jellyfinTies'], {
'japanese': [
{'server': 'server-1', 'item': 'show-1'},
{'server': 'server-2', 'item': 'show-9'},
],
});
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
});
test('are left out while there are none; a payload from before has '
'none', () {
expect(
decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
).keys,
isNot(contains('jellyfinTies')),
);
expect(
decodeSettingsPayload(
encodePayload(8, {'studyTags': <String, Object?>{}}),
).jellyfinTies,
isEmpty,
);
});
test('that are malformed are skipped, keeping the rest', () {
final settings = decodeSettingsPayload(
encodePayload(9, {
'studyTags': <String, Object?>{},
'study': {'newPerDay': 10},
'jellyfinTies': {
'japanese': [
{'server': 'server-1', 'item': 'show-1'},
{'server': 'server-1'},
'show-2',
],
'korean': 'show-3',
},
}),
);
expect(settings.jellyfinTies, {
'japanese': [show],
});
expect(settings.study.newPerDay, 10);
});
test('fold as tags do: for a studyset these settings win, ties of '
'studysets only the other has are added', () {
final mine = const PersonSettings().withJellyfinTie('japanese', show);
final theirs = const PersonSettings()
.withJellyfinTie('japanese', film)
.withJellyfinTie('korean', elsewhere);
expect(mine.foldedWith(theirs).jellyfinTies, {
'japanese': [show],
'korean': [elsewhere],
});
});
});
}