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:
parent
11abd9afce
commit
649499be23
6 changed files with 0 additions and 0 deletions
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue