chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,62 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/ambient_glow.dart';
import 'package:material_ui/material_ui.dart';
/// An image one pixel high, of [colors] in turn.
Future<ui.Image> pixels(List<Color> colors) {
final bytes = Uint8List.fromList([
for (final color in colors) ...[
(color.r * 255).round(),
(color.g * 255).round(),
(color.b * 255).round(),
(color.a * 255).round(),
],
]);
final done = Completer<ui.Image>();
ui.decodeImageFromPixels(
bytes,
colors.length,
1,
ui.PixelFormat.rgba8888,
done.complete,
);
return done.future;
}
void main() {
testWidgets("a picture's colour is its pixels' average, the clear ones "
'left out', (tester) async {
final colour = await tester.runAsync(() async {
final image = await pixels(const [
Color(0xFFFF0000),
Color(0xFF0000FF),
Color(0x00FFFFFF),
]);
try {
return await averageColourOf(image);
} finally {
image.dispose();
}
});
expect(colour, const Color(0xFF7F007F));
});
test('the panel takes the colour no lighter than text stays readable '
'on, keeping its hue', () {
final snow = panelColourOf(const Color(0xFFF4F8FF));
final night = panelColourOf(const Color(0xFF101830));
expect(HSLColor.fromColor(snow).lightness, closeTo(0.35, 0.01));
expect(
HSLColor.fromColor(snow).hue,
closeTo(HSLColor.fromColor(const Color(0xFFF4F8FF)).hue, 1),
);
// Dark enough already: as it is.
expect(night, const Color(0xFF101830));
});
}

View file

@ -0,0 +1,305 @@
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/data/jellyfin_suggestion.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: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, {
String? suggested,
}) 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,
jellyfinSuggestion: JellyfinSuggestion(() async => suggested),
),
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 music library lists its albums and songs; a song without '
'lyrics is greyed and opens nothing, one with lyrics plays', (
tester,
) async {
jellyfin = sampleMusic();
store = signedInJellyfin();
await show(
tester,
RoutePaths.jellyfin(
'japanese',
parentId: 'album-1',
name: 'Sample Album',
),
);
expect(find.text('Sample Song'), findsOneWidget);
expect(find.textContaining('No lyrics'), findsOneWidget);
await tester.tap(find.text('Sample Song (Instrumental)'));
await tester.pumpAndSettle();
expect(find.text('Sample Song (Instrumental)'), findsOneWidget);
await tester.tap(find.text('Sample Song'));
for (var frame = 0; frame < 6; frame++) {
await tester.pump(const Duration(milliseconds: 500));
}
expect(find.text('Words in this song'), findsOneWidget);
});
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 Jellyfin only once this "
'device is signed in to a server (in Settings), as the assistant '
'shows only once switched on', (tester) async {
await show(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
expect(find.text('Create a path'), findsOneWidget);
expect(find.text('From Jellyfin'), findsNothing);
});
testWidgets('signed in, Add material › From Jellyfin opens its '
'libraries', (tester) async {
store = signedInJellyfin();
await show(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tester.tap(find.text('From Jellyfin'));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Libraries'), findsOneWidget);
});
group('a Jellyfin server the instance suggests (decided 2026-10-13)', () {
setUp(() {
jellyfin
..shownUsers = const [
JellyfinPublicUser(id: 'user-2', name: 'Demo'),
JellyfinPublicUser(id: 'user-1', name: 'mika'),
]
..withoutPassword = const {'Demo'};
});
testWidgets('Add material offers it before signing in; the sign-in '
"finds it by itself and lists the server's public people, one "
'without a password signed in with the password left empty', (
tester,
) async {
await show(
tester,
RoutePaths.studyset('japanese'),
suggested: 'https://jellyfin.local:8920',
);
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tester.tap(find.text('From Jellyfin'));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Living room'), findsOneWidget);
expect(find.text('Demo'), findsOneWidget);
await tester.tap(find.text('Demo'));
await tester.pumpAndSettle();
expect(store.kept?.account, isNull);
await tester.tap(find.text('Sign in'));
await tester.pumpAndSettle();
expect(find.text('Libraries'), findsOneWidget);
expect(store.kept?.account?.userName, 'Demo');
});
testWidgets('a tap names one in the form, which asks for the '
'password', (tester) async {
await show(
tester,
RoutePaths.jellyfin('japanese'),
suggested: 'https://jellyfin.local:8920',
);
await tester.tap(find.text('mika'));
await tester.pumpAndSettle();
expect(
tester
.widget<TextField>(find.widgetWithText(TextField, 'User name'))
.controller!
.text,
'mika',
);
expect(
tester
.widget<TextField>(find.widgetWithText(TextField, 'Password'))
.focusNode!
.hasFocus,
isTrue,
);
expect(store.kept?.account, isNull);
});
});
}

View file

@ -0,0 +1,841 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/video/subtitles.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:halkyon_learn_flutter/src/feature/tag/model/tag.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,
}),
// The people the server shows on its login screen, in either case of
// field names; whether one has a password, which servers before v10.11
// said, counts for nothing.
('GET', '/Users/Public') => json([
{'Name': 'Demo', 'Id': 'u-2', 'HasPassword': false},
{'name': 'mika', 'id': 'u-1', 'hasPassword': 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': 'CollectionFolder',
'CollectionType': 'music',
},
{'Name': 'A book', 'Id': 'book-1', 'Type': 'Book'},
],
'TotalRecordCount': 2,
}),
('GET', '/Items/song-1') => json({
'Name': '幽霊船戦',
'Id': 'song-1',
'Type': 'Audio',
'AlbumArtists': [
{'Name': '宝鐘マリン', 'Id': 'artist-1'},
],
'ProviderIds': {'MusicBrainzTrack': 'mb-track-1'},
'AlbumArtist': '宝鐘マリン',
'Album': 'Ahoy!!',
'HasLyrics': true,
'IndexNumber': 3,
'ParentIndexNumber': 1,
'RunTimeTicks': 2000000000,
'ImageTags': {'Primary': 'a1'},
}),
('GET', '/Audio/song-1/Lyrics') => json({
'Metadata': <String, Object?>{},
'Lyrics': [
{'Text': '海賊なら Yo-Ho!', 'Start': 68200000, 'Cues': <Object?>[]},
{'Text': 'さぁ今旗のもとへ', 'Start': 95800000, 'Cues': <Object?>[]},
],
}),
('GET', '/Items/ep-1') => json({
'Name': 'Sample Journey S01E01',
'Id': 'ep-1',
'Type': 'Episode',
'SeriesName': 'Sample Journey',
'SeriesId': 'series-1',
'ProviderIds': {'Tmdb': '9001', 'Tvdb': ''},
'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,
),
('GET', '/Items/series-1') => json({
'Name': 'Sample Journey',
'Id': 'series-1',
'Type': 'Series',
'providerIds': {'Tmdb': '219109', 'Tvdb': '445566'},
}),
('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 the people a server shows on its login screen', () async {
final server = await gateway.server(
Uri.parse('http://jellyfin.local:8096'),
);
expect(await gateway.publicUsers(server), const [
JellyfinPublicUser(id: 'u-2', name: 'Demo'),
JellyfinPublicUser(id: 'u-1', name: 'mika'),
]);
});
test('lists libraries of videos and of music, leaving out what is '
'neither', () async {
final account = await signIn();
final libraries = await gateway.libraries(account);
expect(libraries, [
const JellyfinItem(
id: 'lib-1',
name: 'Anime',
kind: JellyfinItemKind.library,
),
const JellyfinItem(
id: 'lib-2',
name: 'Music',
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('an item says what the metadata providers call it, and an episode '
'its show, a song its artist', () async {
final account = await signIn();
final episode = await gateway.item(account, 'ep-1');
expect(episode.providerIds, {'tmdb': '9001'});
expect(episode.seriesId, 'series-1');
expect((await gateway.item(account, 'series-1')).providerIds, {
'tmdb': '219109',
'tvdb': '445566',
});
final song = await gateway.item(account, 'song-1');
expect(song.artistId, 'artist-1');
expect(song.providerIds, {'musicbrainztrack': 'mb-track-1'});
});
test('a song is one, with its artist and whether it has lyrics, its '
'lyrics timed line by line, none for a song without; a song streams '
'as sound', () async {
final account = await signIn();
final song = await gateway.item(account, 'song-1');
expect(song.kind, JellyfinItemKind.audio);
expect(song.artist, '宝鐘マリン');
expect(song.hasLyrics, isTrue);
expect(song.seasonNumber, isNull);
expect(await gateway.lyrics(account, 'song-1'), [
(text: '海賊なら Yo-Ho!', start: const Duration(milliseconds: 6820)),
(text: 'さぁ今旗のもとへ', start: const Duration(milliseconds: 9580)),
]);
expect(await gateway.lyrics(account, 'song-2'), isEmpty);
expect(
gateway
.streamOf(
account,
const JellyfinPlayback(
itemId: 'song-1',
mediaSourceId: 'song-1',
playSessionId: 'ps-2',
subtitles: [],
audioOnly: true,
),
)
.path,
'/Audio/song-1/stream',
);
});
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("a song's lyrics are lines, each until the next, the last until "
'the song ends; empty ones left out; untimed lyrics are text '
'alone', () async {
JellyfinRepository with_(List<({String text, Duration? start})> lines) =>
JellyfinRepository(
(_) => FakeJellyfinGateway(lyricsOf: {'song-1': lines}),
signedInJellyfin(),
deviceName: 'Test',
appVersion: '1',
);
final timed = await with_(const [
(text: '一', start: Duration(seconds: 1)),
(text: ' ', start: Duration(seconds: 4)),
(text: '二', start: Duration(seconds: 6)),
]).lyrics('song-1', runTime: const Duration(minutes: 3));
expect(timed.synced, isTrue);
expect(timed.lines, const [
SubtitleCue(
start: Duration(seconds: 1),
end: Duration(seconds: 6),
text: '一',
),
SubtitleCue(
start: Duration(seconds: 6),
end: Duration(minutes: 3),
text: '二',
),
]);
final untimed = await with_(const [
(text: '一', start: null),
(text: '二', start: null),
]).lyrics('song-1');
expect(untimed.synced, isFalse);
expect([for (final line in untimed.lines) line.text], ['一', '二']);
});
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);
});
});
group('the tags a word met on Jellyfin gets', () {
const series = JellyfinItem(
id: 'series-1',
name: 'あかね噺',
kind: JellyfinItemKind.series,
providerIds: {'tmdb': '219109'},
);
const episode = JellyfinItem(
id: 'ep-1',
name: '第1話',
kind: JellyfinItemKind.episode,
seriesName: 'あかね噺',
seriesId: 'series-1',
seasonNumber: 1,
episodeNumber: 1,
providerIds: {'tmdb': '9001'},
);
const show = JellyfinTagId(
ids: {'tmdb': '219109', 'jellyfin:server-1': 'series-1'},
);
test("an episode: its show, by the show's ids, and itself, by them and "
'its number', () {
expect(jellyfinTagsOf(episode, serverId: 'server-1', whole: series), (
whole: (name: 'あかね噺', id: show),
part: (
name: 'あかね噺 S1 E1',
id: JellyfinTagId(ids: show.ids, part: 'S1E1'),
),
));
});
test('without its show read, the show is known by its id on the '
'server', () {
expect(
jellyfinTagsOf(episode, serverId: 'server-1').whole.id,
const JellyfinTagId(ids: {'jellyfin:server-1': 'series-1'}),
);
});
test('a film is one tag, by its own ids', () {
expect(
jellyfinTagsOf(
const JellyfinItem(
id: 'film-1',
name: 'CODA',
kind: JellyfinItemKind.movie,
providerIds: {'tmdb': '776503', 'imdb': 'tt10366460'},
),
serverId: 'server-1',
),
(
whole: (
name: 'CODA',
id: const JellyfinTagId(
ids: {
'tmdb': '776503',
'imdb': 'tt10366460',
'jellyfin:server-1': 'film-1',
},
),
),
part: null,
),
);
});
test("a song: its artist, by the artist's ids, and itself, by its "
'own', () {
const song = JellyfinItem(
id: 'song-1',
name: 'Ahoy!!',
kind: JellyfinItemKind.audio,
artist: '宝鐘マリン',
artistId: 'artist-1',
providerIds: {'musicbrainztrack': 'mb-track-1'},
);
const marine = JellyfinItem(
id: 'artist-1',
name: '宝鐘マリン',
kind: JellyfinItemKind.artist,
providerIds: {'musicbrainzartist': 'mb-artist-1'},
);
const artist = JellyfinTagId(
ids: {
'musicbrainzartist': 'mb-artist-1',
'jellyfin:server-1': 'artist-1',
},
);
expect(jellyfinTagsOf(song, serverId: 'server-1', whole: marine), (
whole: (name: '宝鐘マリン', id: artist),
part: (
name: '宝鐘マリン · Ahoy!!',
id: JellyfinTagId(
ids: artist.ids,
part: 'musicbrainztrack:mb-track-1',
),
),
));
});
});
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,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_suggestion.dart';
void main() {
test('asks the instance once, as it is first needed', () async {
var asked = 0;
final suggestion = JellyfinSuggestion(() async {
asked++;
return 'https://jellyfin.halkyon.eu/';
});
expect(suggestion.server.value, isNull);
await suggestion.look();
await suggestion.look();
expect(asked, 1);
expect(suggestion.server.value, Uri.parse('https://jellyfin.halkyon.eu/'));
});
test('asks again only after asking failed; none named is none', () async {
var asked = 0;
final suggestion = JellyfinSuggestion(() async {
asked++;
if (asked == 1) {
throw Exception('offline');
}
return null;
});
await suggestion.look();
expect(suggestion.server.value, isNull);
await suggestion.look();
await suggestion.look();
expect(asked, 2);
expect(suggestion.server.value, isNull);
});
}

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,971 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionaries.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_jellyfin.dart';
import '../../support/fake_review_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/fake_tag_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/sample_episode.dart';
import '../../support/test_dependencies.dart';
void main() {
late FakeCardGateway cardGateway;
late FakeTagGateway tagGateway;
late CardRepository cards;
late TagRepository tags;
late FakeJellyfinGateway jellyfin;
late FakeMediaPlayers players;
late FakeStudysetGateway studysets;
late FakeSettingsGateway settingsGateway;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: 'ja',
backLanguage: 'en',
),
),
);
settingsGateway = FakeSettingsGateway();
cardGateway = FakeCardGateway();
tagGateway = FakeTagGateway();
cards = CardRepository(cardGateway);
tags = TagRepository(tagGateway);
jellyfin = sampleJellyfin();
players = FakeMediaPlayers();
});
tearDown(() async {
await cards.dispose();
await tags.dispose();
});
/// Opens the sample episode from the Japanese studyset, in a window of
/// [size].
Future<void> watch(
WidgetTester tester, {
Size size = const Size(1400, 900),
bool playerAvailable = true,
JellyfinPlayback? playback,
Map<(String, int), String>? vtt,
List<MediaAudioTrack> audio = const [],
List<DictionaryWord>? words,
FakeReviewGateway? reviews,
FakeJellyfinGateway? gateway,
String itemId = 'episode-1',
FakeDictionaryGateway? dictionary,
DeviceDictionaries? device,
}) async {
if (gateway != null) {
jellyfin = gateway;
}
if (playback != null || vtt != null) {
jellyfin = sampleJellyfin(
playback: playback ?? sampleEpisodePlayback,
vtt:
vtt ??
{('episode-1', 2): sampleJapanese, ('episode-1', 3): sampleEnglish},
);
}
if (audio.isNotEmpty) {
players = FakeMediaPlayers(audio: audio);
}
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
if (!playerAvailable) {
players = FakeMediaPlayers(available: false);
}
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
settingsGateway: settingsGateway,
cardGateway: cardGateway,
tagGateway: tagGateway,
dictionaryGateway:
dictionary ??
(words == null
? sampleDictionary()
: FakeDictionaryGateway(words: words)),
deviceDictionaries: device,
reviewGateway: reviews,
jellyfinGateway: jellyfin,
jellyfinStore: signedInJellyfin(),
mediaPlayers: players,
),
location: RoutePaths.watch('japanese', itemId),
);
await tester.pumpAndSettle();
}
/// Adds words as the screen does, into [studysets].
DictionaryAdder adder() => DictionaryAdder(
cards,
tags,
StudysetRepository(studysets),
SettingsRepository(settingsGateway),
);
/// Plays on to [position].
Future<void> playTo(WidgetTester tester, Duration position) async {
players.last!.moveTo(position);
await tester.pumpAndSettle();
}
testWidgets('the line shown is cut into words, their readings above, its '
'translation beneath; it follows the video', (tester) async {
await watch(tester);
expect(players.opened.single.headers['Authorization'], contains('token-1'));
expect(jellyfin.reports.single.started, isTrue);
await playTo(tester, const Duration(seconds: 2));
expect(find.text('友達'), findsOneWidget);
expect(find.text('ともだち'), findsOneWidget);
// 会いました: 会 read あ, its ending as it stands.
expect(find.text('会'), findsOneWidget);
expect(find.text('あ'), findsOneWidget);
expect(find.text('いました'), findsOneWidget);
expect(find.text('Yesterday I met a friend at the station.'), findsOne);
// Between lines the last one stays.
await playTo(tester, const Duration(milliseconds: 5500));
expect(find.text('友達'), findsOneWidget);
await playTo(tester, const Duration(seconds: 12));
expect(find.text('旅'), findsOneWidget);
expect(find.text('友達'), findsNothing);
});
testWidgets('offers once to look words up on the device, saying how much '
'it downloads; Not now offers it no more', (tester) async {
final device = deviceDictionariesIn(
FakeDeviceDictionaryGateway(offered: offeredJapanese()),
);
await watch(tester, device: device);
expect(find.text('Look words up on this device?'), findsOneWidget);
expect(find.textContaining('(34.0 MB, more once set up)'), findsOneWidget);
await tester.tap(find.text('Not now'));
await tester.pumpAndSettle();
expect(find.text('Look words up on this device?'), findsNothing);
expect(device.status.asked, isTrue);
expect(device.status.progress, isNull);
});
testWidgets('with Japanese’s dictionaries on the device, lines are cut '
'there, and the instance never sees them', (tester) async {
final analyser = FakeJapaneseAnalyser(
cut: (sentence) => [
SentencePart(text: sentence, start: 0, entries: [sampleWords.first]),
],
);
final dictionary = sampleDictionary();
await watch(
tester,
dictionary: dictionary,
device: deviceDictionariesIn(
FakeDeviceDictionaryGateway(offered: offeredJapanese()),
analyser: analyser,
initial: DeviceDictionaryStatus(
installed: installedJapanese(),
asked: true,
),
),
);
await playTo(tester, const Duration(seconds: 2));
expect(
analyser.asked.expand((sentences) => sentences),
contains('昨日、駅で友達に会いました。'),
);
expect(dictionary.keysAsked, isEmpty);
expect(find.text('Look words up on this device?'), findsNothing);
});
testWidgets('a word tapped pauses the video and shows what it is a form '
"of; added, it is a flashcard with the dictionary's checked examples "
'(never its line, which may be wrong), tagged with the show and the '
'episode as tags from Jellyfin, and is marked from then on', (
tester,
) async {
await watch(tester);
await players.last!.play();
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('いました'));
await tester.pumpAndSettle();
expect(players.last!.state.value.playing, isFalse);
expect(find.text('A form of 会う'), findsOneWidget);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(find.text('会う added'), findsOneWidget);
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.type, CardType.vocabulary);
expect(card.content.entry, (source: 'jmdict', id: '6'));
expect(card.content.hints, hasLength(1));
expect(card.content.hints.single.plainText, contains('また会いましょう。'));
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Journey', 'Sample Journey S1 E1', 'JLPT-N5']),
);
expect(tagging.named('Sample Journey S1 E1')?.fromJellyfin, isTrue);
expect(tagging.named('Sample Journey')?.fromJellyfin, isTrue);
// How far it is learnt: not yet.
expect(find.text('New'), findsOneWidget);
});
testWidgets('a show the server calls otherwise now keeps its tags: a word '
'added gets those standing for the show and the episode, whatever '
'they are called', (tester) async {
final show = await tags.create(
'旅の記録',
fromJellyfin: true,
jellyfinId: const JellyfinTagId(ids: {'tmdb': '424242'}),
);
final episode = await tags.create(
'旅の記録 1-1',
fromJellyfin: true,
jellyfinId: const JellyfinTagId(ids: {'tmdb': '424242'}, part: 'S1E1'),
);
await watch(tester);
await players.last!.play();
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('いました'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
final card = (await cards.cardsOf('japanese')).single;
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.id],
containsAll([show, episode]),
);
expect(tagging.named('Sample Journey'), isNull);
expect(tagging.named('Sample Journey S1 E1'), isNull);
});
testWidgets("the video's words are listed, the most frequent first, "
'particles left out; one opens its first line', (tester) async {
await watch(tester);
expect(find.text('Words in this video'), findsOneWidget);
final words = [
for (final tile in tester.widgetList<ListTile>(
find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(ListTile),
),
))
(tile.title! as Text).textSpan!.toPlainText().split(' ').first,
];
// 駅 and 友達 come twice; で, に, を, けど and だ are grammar.
expect(words.take(2).toSet(), {'駅', '友達'});
expect(words, isNot(contains('で')));
// Further down the list.
final wait = find.textContaining('待つ', findRichText: true);
await tester.scrollUntilVisible(
wait,
100,
scrollable: find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
await tester.pumpAndSettle();
await tester.tap(wait.first);
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 16));
expect(find.text('A form of 待つ'), findsOneWidget);
});
testWidgets('the next line and the one before play from their start, the '
'one shown from its start once under way; a line can end with a '
'pause', (tester) async {
await watch(tester);
await playTo(tester, const Duration(seconds: 7));
await tester.tap(find.byTooltip('Next line'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 11));
expect(players.last!.state.value.playing, isTrue);
// Under way: the one shown again, from its start.
await playTo(tester, const Duration(seconds: 14));
await tester.tap(find.byTooltip('Previous line'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 11));
await tester.tap(find.byTooltip('Previous line'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 6));
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
await tester.tap(find.text('Pause after each line'));
await tester.pumpAndSettle();
await playTo(tester, const Duration(milliseconds: 9900));
expect(players.last!.state.value.playing, isFalse);
});
testWidgets('leaving tells the server where the video stopped', (
tester,
) async {
await watch(tester);
await playTo(tester, const Duration(seconds: 12));
GoRouter.of(tester.element(find.byType(WatchScreen))).go(RoutePaths.home);
await tester.pumpAndSettle();
expect(jellyfin.reports.last.stopped, isTrue);
expect(jellyfin.reports.last.position, const Duration(seconds: 12));
expect(players.last!.disposed, isTrue);
});
testWidgets('without a player the subtitles still show, and the screen '
'says why the video does not', (tester) async {
await watch(tester, playerAvailable: false);
expect(find.textContaining("Videos can't play on this device"), findsOne);
expect(find.text('昨日'), findsNothing);
expect(find.text('Words in this video'), findsOneWidget);
});
testWidgets("the sound is in the studyset's language, whichever track the "
'file plays first; the menu switches it', (tester) async {
await watch(
tester,
playback: const JellyfinPlayback(
itemId: 'episode-1',
mediaSourceId: 'episode-1',
playSessionId: 'session-1',
runTime: Duration(minutes: 24),
subtitles: [
JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'),
JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'),
],
audio: [
JellyfinAudioTrack(
index: 4,
title: 'German',
language: 'ger',
isDefault: true,
),
JellyfinAudioTrack(
index: 5,
title: 'English',
language: 'eng',
isDefault: true,
),
JellyfinAudioTrack(
index: 6,
title: 'Japanese [Original]',
language: 'jpn',
isDefault: true,
),
],
),
// As Android names them.
audio: const [
MediaAudioTrack(id: 'a', language: 'de'),
MediaAudioTrack(id: 'b', language: 'en'),
MediaAudioTrack(id: 'c', language: 'ja'),
],
);
expect(players.last!.selectedAudio?.id, 'c');
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
// Tracks are named by their language, plainly.
expect(find.text('Japanese'), findsWidgets);
await tester.tap(find.text('Audio'));
await tester.pumpAndSettle();
await tester.tap(find.text('German'));
await tester.pump();
await tester.pumpAndSettle();
expect(players.last!.selectedAudio?.id, 'a');
});
testWidgets("a forced track, which only covers what the sound doesn't "
'say, is never the one read', (tester) async {
await watch(
tester,
playback: const JellyfinPlayback(
itemId: 'episode-1',
mediaSourceId: 'episode-1',
playSessionId: 'session-1',
runTime: Duration(minutes: 24),
subtitles: [
JellyfinSubtitleTrack(
index: 7,
title: 'Japanese (forced)',
language: 'jpn',
isDefault: true,
isForced: true,
),
JellyfinSubtitleTrack(index: 2, title: 'Japanese', language: 'jpn'),
JellyfinSubtitleTrack(index: 3, title: 'English', language: 'eng'),
],
),
);
await playTo(tester, const Duration(seconds: 2));
expect(find.text('友達'), findsOneWidget);
});
testWidgets('a line takes the whole width, its translation on one line '
'too; the video keeps its size however long the line is', (
tester,
) async {
final long = List.filled(8, '昨日、駅で友達に会いました。').join();
await watch(
tester,
vtt: {
('episode-1', 2):
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n旅\n\n'
'00:00:06.000 --> 00:00:10.000\n$long\n',
('episode-1', 3):
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA journey.\n\n'
'00:00:06.000 --> 00:00:10.000\n'
'So I say, "Here you go!",\nand I lent it to him.\n',
},
);
await playTo(tester, const Duration(seconds: 2));
final controls = tester.getTopLeft(find.byTooltip('Previous line'));
await playTo(tester, const Duration(seconds: 7));
expect(
find.text('So I say, "Here you go!", and I lent it to him.'),
findsOneWidget,
);
expect(tester.getTopLeft(find.byTooltip('Previous line')), controls);
expect(tester.takeException(), isNull);
});
testWidgets('resizing the window, across every size class, keeps the '
'video where it is', (tester) async {
await watch(tester);
await playTo(tester, const Duration(minutes: 2));
final playing = players.last;
for (final size in const [
Size(800, 900),
Size(500, 900),
Size(1400, 900),
]) {
tester.view.physicalSize = size;
await tester.pumpAndSettle();
}
expect(players.opened, hasLength(1));
expect(players.last, same(playing));
expect(playing!.disposed, isFalse);
expect(playing.state.value.position, const Duration(minutes: 2));
});
testWidgets('the player has play or pause with 10 seconds back and on '
'beside it, each a short tick where the device vibrates, and where it '
'is from 0:00 to its length', (tester) async {
final ticks = <Object?>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
ticks.add(call.arguments);
}
return null;
},
);
await watch(tester);
await playTo(tester, const Duration(minutes: 1));
expect(find.text('1:00'), findsOneWidget);
expect(find.text('24:00'), findsOneWidget);
await tester.tap(find.byTooltip('10 seconds on'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 70));
await tester.tap(find.byTooltip('10 seconds back'));
await tester.tap(find.byTooltip('10 seconds back'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 50));
expect(ticks, List.filled(3, 'HapticFeedbackType.selectionClick'));
});
testWidgets("YouTube's keys work, the lines' too, and ? lists them", (
tester,
) async {
await watch(tester);
await playTo(tester, const Duration(minutes: 1));
Duration at() => players.last!.state.value.position;
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
expect(at(), const Duration(seconds: 70));
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
expect(at(), const Duration(seconds: 55));
await tester.sendKeyEvent(LogicalKeyboardKey.digit5);
expect(at(), const Duration(minutes: 12));
await tester.sendKeyEvent(LogicalKeyboardKey.home);
expect(at(), Duration.zero);
await playTo(tester, const Duration(seconds: 7));
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pumpAndSettle();
expect(at(), const Duration(seconds: 11));
await tester.sendKeyEvent(LogicalKeyboardKey.keyP);
await tester.pumpAndSettle();
await playTo(tester, const Duration(milliseconds: 14900));
expect(players.last!.state.value.playing, isFalse);
await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?');
await tester.sendKeyUpEvent(LogicalKeyboardKey.slash);
await tester.pumpAndSettle();
expect(find.text('Keyboard shortcuts'), findsOneWidget);
expect(find.text('Previous or next line'), findsOneWidget);
});
testWidgets('the controls fade out while it plays and the pointer rests, '
'and come back while paused', (tester) async {
await watch(tester);
double shown() => tester
.widget<AnimatedOpacity>(
find
.ancestor(
of: find.byTooltip('10 seconds on'),
matching: find.byType(AnimatedOpacity),
)
.first,
)
.opacity;
expect(shown(), 1);
await tester.tap(find.byTooltip('Play'));
await tester.pump();
await tester.pump(const Duration(seconds: 4));
expect(shown(), 0);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
expect(shown(), 1);
});
testWidgets('a word the studyset holds says how far it is learnt, in the '
'list of the words it holds', (tester) async {
final station = sampleWords.firstWhere((word) => word.headword == '駅');
await adder().add('japanese', [station]);
final card = (await cards.cardsOf('japanese')).single;
final reviews = FakeReviewGateway();
await reviews.insert([
(
id: '',
cardId: card.id,
rating: 4,
reviewedAt: DateTime.now().toUtc().subtract(const Duration(days: 1)),
durationMs: 3000,
mode: 'review',
),
]);
await watch(tester, reviews: reviews);
await tester.tap(find.byTooltip('Filter the words'));
await tester.pumpAndSettle();
await tester.tap(find.text('Words in the studyset'));
await tester.pumpAndSettle();
expect(find.textContaining('駅', findRichText: true), findsOneWidget);
expect(find.textContaining('Learned · again'), findsOneWidget);
});
testWidgets("watching from a studyset with topics, a topic's word shows "
'as held, and a new one goes where Adds to says, shown as added at '
'once', (tester) async {
studysets.receiveRemoteChange(
'n3',
encodeStudysetPayload(StudysetContent.titled('N3')),
parentId: 'japanese',
);
final station = sampleWords.firstWhere((word) => word.headword == '駅');
await adder().add('n3', [station]);
await watch(tester);
await tester.tap(find.byTooltip('Filter the words'));
await tester.pumpAndSettle();
await tester.tap(find.text('Words in the studyset'));
await tester.pumpAndSettle();
expect(find.textContaining('駅', findRichText: true), findsOneWidget);
// The filter's menu stays open for more choices.
await tester.tap(find.byTooltip('Filter the words'));
await tester.pumpAndSettle();
await tester.tap(find.text('Adds to Japanese'));
await tester.pumpAndSettle();
// The words' levels say N3 too.
await tester.tap(find.widgetWithText(MenuItemButton, 'N3'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Adds to N3'), findsOneWidget);
await players.last!.play();
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('いました'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pump();
// Held and new at once, before the card is written.
expect(find.text('Add'), findsNothing);
expect(find.text('New'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('会う added'), findsOneWidget);
expect(
[for (final card in await cards.cardsOf('n3')) card.content.entry?.id],
containsAll(['6']),
);
expect(await cards.cardsOf('japanese'), isEmpty);
});
testWidgets('the filter narrows the words by their class', (tester) async {
await watch(tester);
expect(find.textContaining('駅', findRichText: true), findsOneWidget);
await tester.tap(find.byTooltip('Filter the words'));
await tester.pumpAndSettle();
await tester.tap(find.text('Verbs'));
await tester.pumpAndSettle();
expect(find.textContaining('会う', findRichText: true), findsOneWidget);
expect(find.textContaining('駅', findRichText: true), findsNothing);
});
testWidgets('a word of many senses unfolds over the list under the '
'pointer, a tap keeping it open; senses one to a line', (tester) async {
final long = [
for (final word in sampleWords)
if (word.headword == '駅')
DictionaryWord(
ref: word.ref,
headword: word.headword,
reading: word.reading,
labels: word.labels,
senses: [
for (var n = 1; n <= 6; n++)
VocabularySense(
glosses: ['station, as in sense $n of a long entry'],
pos: const ['n'],
),
],
)
else
word,
];
await watch(tester, words: long);
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('駅'));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsNothing);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: Offset.zero);
await mouse.moveTo(
tester.getCenter(find.textContaining('sense 1 of a long').first),
);
await tester.pump(const Duration(milliseconds: 700));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsOneWidget);
expect(find.text('station, as in sense 6 of a long entry'), findsOneWidget);
// Left, it folds again.
await mouse.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsNothing);
// Tapped, it stays open with the pointer elsewhere; a tap on it closes
// it.
await tester.tap(find.textContaining('sense 1 of a long').first);
await tester.pumpAndSettle();
await mouse.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsOneWidget);
// Its first line: the card scrolls, and its middle may lie beyond it.
await tester.tap(
find.descendant(
of: find.byType(VocabularySensesView),
matching: find.textContaining('sense 1 of a long'),
),
);
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsNothing);
});
testWidgets('a word tapped also offers the longer words running over it, '
'to add with the line', (tester) async {
await watch(
tester,
vtt: {
('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nがまだ\n',
('episode-1', 3): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nStill.\n',
},
words: [
word('31', 'が', pos: ['prt'], labels: ['common']),
word('37', 'がま', pos: ['n'], glosses: ['bulrush']),
word('38', 'まだ', pos: ['adv'], labels: ['common']),
],
);
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('が'));
await tester.pumpAndSettle();
expect(find.text('Longer words here'), findsOneWidget);
expect(find.textContaining('bulrush', findRichText: true), findsOneWidget);
await tester.tap(find.text('Add').last);
await tester.pumpAndSettle();
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.entry, (source: 'jmdict', id: '37'));
});
group('a stretch chosen by hand (decided 2026-10-13: by characters)', () {
final greetings = [
word('40', '今日', reading: 'きょう', pos: ['n'], glosses: ['today']),
word('41', '今日は', reading: 'こんにちは', pos: ['int'], glosses: ['hello']),
word('42', 'は', pos: ['prt'], labels: ['common']),
word('43', '雨', reading: 'あめ', pos: ['n'], glosses: ['rain']),
word('44', 'だ', pos: ['cop'], labels: ['common']),
];
Future<void> watchGreetings(WidgetTester tester) async {
await watch(
tester,
vtt: {
('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は雨だ\n',
('episode-1', 3):
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA grey sky.\n',
},
words: greetings,
);
await playTo(tester, const Duration(seconds: 2));
}
/// The middle of the [index]th character of the base text [text] of
/// the line (four characters to a run at most here, each as wide).
Offset character(WidgetTester tester, String text, int index) {
final rect = tester.getRect(find.text(text, findRichText: true).first);
final width = rect.width / text.length;
return Offset(rect.left + width * (index + 0.5), rect.center.dy);
}
testWidgets('dragged across with a mouse, the characters chosen are '
'looked up as one word, also inside a word the cut took longer: '
'今日 out of 今日は', (tester) async {
await watchGreetings(tester);
await players.last!.play();
expect(find.text('今日', findRichText: true), findsOneWidget);
final drag = await tester.startGesture(
character(tester, '今日', 0),
kind: PointerDeviceKind.mouse,
);
await drag.moveTo(character(tester, '今日', 1));
await drag.up();
await tester.pumpAndSettle();
expect(players.last!.state.value.playing, isFalse);
expect(find.textContaining('today', findRichText: true), findsOneWidget);
expect(find.textContaining('hello', findRichText: true), findsNothing);
// While chosen, the line reads it as the word chosen: きょう, not
// こんにち of こんにちは.
expect(find.text('こんにち'), findsNothing);
expect(find.text('きょう'), findsWidgets);
});
testWidgets('Shift+click chooses from the word chosen to the character '
'clicked; what the dictionary lacks as one word is searched on '
'Jisho', (tester) async {
await watchGreetings(tester);
await tester.tap(find.text('雨', findRichText: true));
await tester.pumpAndSettle();
expect(find.textContaining('rain', findRichText: true), findsOneWidget);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
final click = await tester.startGesture(
character(tester, 'だ', 0),
kind: PointerDeviceKind.mouse,
);
await click.up();
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pumpAndSettle();
expect(
find.text("雨だ isn't in the dictionary as one word."),
findsOneWidget,
);
expect(find.text('Search Jisho for it'), findsOneWidget);
expect(find.textContaining('rain', findRichText: true), findsNothing);
});
testWidgets('on a phone, holding a character starts a stretch, and '
'moving on extends it', (tester) async {
await watchGreetings(tester);
final hold = await tester.startGesture(character(tester, '今日', 0));
await tester.pump(const Duration(milliseconds: 600));
await hold.moveTo(character(tester, '今日', 1));
await hold.up();
await tester.pumpAndSettle();
expect(find.textContaining('today', findRichText: true), findsOneWidget);
});
});
testWidgets("the episode's words can be studied or crammed: the cards of "
'its words, held before it was watched too, get its tag', (tester) async {
final station = sampleWords.firstWhere((word) => word.headword == '駅');
await adder().add('japanese', [station]);
final card = (await cards.cardsOf('japanese')).single;
await watch(tester);
await tester.tap(find.byTooltip('Play'));
await tester.pump();
await tester.tap(find.text('Study'));
await tester.pumpAndSettle();
// The words listed it lacks can be added from here too.
expect(
find.textContaining(RegExp(r'^Add the \d+ new words listed$')),
findsOneWidget,
);
await tester.tap(find.text('Cram, each once'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
contains('Sample Journey S1 E1'),
);
expect(players.last!.state.value.playing, isFalse);
// The session, over the Watch screen, on the episode's tag, crammed (a
// spinner turns there: frames, not settling).
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
final location = GoRouterState.of(
tester.element(find.byType(StudyScreen)),
).uri;
expect(location.path, '/study/japanese');
expect(location.queryParameters, containsPair('cram', '1'));
expect(
location.queryParameters,
containsPair('tagName', 'Sample Journey S1 E1'),
);
});
testWidgets("with none of the video's words held, the button adds the "
'words listed, tagged from Jellyfin, and then studies them', (
tester,
) async {
await watch(tester);
final add = find.textContaining(RegExp(r'^Add \d+ words$'));
expect(add, findsOneWidget);
final listed = int.parse(
RegExp(r'\d+').firstMatch(tester.widget<Text>(add).data!)![0]!,
);
expect(listed, greaterThan(1));
await tester.tap(add);
await tester.pumpAndSettle();
expect(find.text('$listed words added'), findsOneWidget);
final added = await cards.cardsOf('japanese');
expect(added, hasLength(listed));
final tagging = await tags.read();
for (final card in added) {
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Journey', 'Sample Journey S1 E1']),
);
}
expect(find.text('Study'), findsOneWidget);
});
testWidgets("a song's lyrics move with it, its words looked up as a "
"video's; a word added is tagged with the artist and the song", (
tester,
) async {
await watch(tester, gateway: sampleMusic(), itemId: 'song-1');
expect(find.text('Words in this song'), findsOneWidget);
// Every line shows, cut into words; the empty one is left out.
expect(find.text('友達'), findsOneWidget);
expect(find.text('読'), findsOneWidget);
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('駅'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add').first);
await tester.pumpAndSettle();
final card = (await cards.cardsOf('japanese')).single;
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Artist', 'Sample Artist · Sample Song']),
);
expect(
tagging.named('Sample Artist · Sample Song')?.fromJellyfin,
isTrue,
);
});
testWidgets('on a phone the words are below the video', (tester) async {
await watch(tester, size: const Size(412, 915));
await playTo(tester, const Duration(seconds: 2));
expect(find.text('友達'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}