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
4 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionaries.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionary_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/japanese_analyser.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
// Japanese cut on the device for real (ADR 0012): MeCab as the package's
|
||||
// build hook compiles it for the platform, IPADIC as the curator packs it,
|
||||
// and JMdict as an instance exports it. On the desktop,
|
||||
//
|
||||
// flutter test integration_test/japanese_analyser_test.dart -d linux \
|
||||
// --dart-define=IPADIC_PACK=<mecab-ipadic.tar.gz> \
|
||||
// --dart-define=JMDICT_EXPORT=<jmdict-en-….jsonl.gz>
|
||||
//
|
||||
// with the files an instance serves at /device-dictionaries/ (both named in
|
||||
// `dictionary.deviceFiles('ja')`); the curator's `curate ipadic` writes the
|
||||
// pack.
|
||||
const _pack = String.fromEnvironment('IPADIC_PACK');
|
||||
const _export = String.fromEnvironment('JMDICT_EXPORT');
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory folder;
|
||||
|
||||
setUpAll(() async {
|
||||
folder = Directory.systemTemp.createTempSync('japanese');
|
||||
unpackIpadic(File(_pack), Directory('${folder.path}/ipadic'));
|
||||
await buildDeviceDictionary(
|
||||
File(_export),
|
||||
File('${folder.path}/jmdict.sqlite'),
|
||||
);
|
||||
});
|
||||
tearDownAll(() => folder.deleteSync(recursive: true));
|
||||
|
||||
String cut(List<SentencePart> parts) => [
|
||||
for (final part in parts)
|
||||
if (part.isWord) part.text else '(${part.text})',
|
||||
].join(' ');
|
||||
|
||||
testWidgets('cuts lines into words as Jisho.org does, and finds each in '
|
||||
'JMdict', (tester) async {
|
||||
final lines = await tester.runAsync(
|
||||
() => IsolateJapaneseAnalyser().wordsOf(
|
||||
ipadic: '${folder.path}/ipadic',
|
||||
database: '${folder.path}/jmdict.sqlite',
|
||||
sentences: ['今日はいい天気ですね。', 'お前がもうろくだよ', '勉強している', '静かな部屋'],
|
||||
),
|
||||
);
|
||||
|
||||
final [weather, senile, study, room] = lines!;
|
||||
expect(cut(weather), '今日 は いい 天気 です ね (。)');
|
||||
expect(weather.first.entries.first.headword, '今日');
|
||||
expect(cut(senile), 'お前 が もうろく だ よ');
|
||||
expect(senile[2].entries.first.headword, '耄碌');
|
||||
expect(study.single.entries.first.headword, '勉強');
|
||||
expect(study.single.steps, isNotEmpty);
|
||||
expect(cut(room), '静かな 部屋');
|
||||
expect(room.first.entries.first.headword, '静か');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// The video player for real (ExoPlayer on Android, libmpv on Linux and
|
||||
// Windows), streaming from a Jellyfin server, which `flutter test` can't:
|
||||
// on a device or the desktop,
|
||||
//
|
||||
// flutter test integration_test/jellyfin_player_test.dart -d <device> \
|
||||
// --dart-define=JELLYFIN_URL=https://jellyfin.example.org \
|
||||
// --dart-define=JELLYFIN_USER=<name> \
|
||||
// --dart-define=JELLYFIN_PASSWORD=<password>
|
||||
//
|
||||
// with a server holding at least one episode, film or video with text
|
||||
// subtitles, reached over HTTPS as the app reaches it (Android refuses plain
|
||||
// HTTP to it). A Linux build needs libmpv-dev.
|
||||
const _url = String.fromEnvironment('JELLYFIN_URL');
|
||||
const _user = String.fromEnvironment('JELLYFIN_USER');
|
||||
const _password = String.fromEnvironment('JELLYFIN_PASSWORD');
|
||||
|
||||
void main() {
|
||||
// Frames as the app draws them, as the picture is checked.
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized().framePolicy =
|
||||
LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
|
||||
testWidgets('streams a video with subtitles from the server and plays it', (
|
||||
tester,
|
||||
) async {
|
||||
final client = http.Client();
|
||||
addTearDown(client.close);
|
||||
final gateway = HttpJellyfinGateway(
|
||||
client,
|
||||
device: (id: 'device-check', name: 'Device check', version: '0'),
|
||||
);
|
||||
final found = await tester.runAsync(() async {
|
||||
final server = await gateway.server(Uri.parse(_url));
|
||||
final account = await gateway.signIn(server, _user, _password);
|
||||
final videos = await gateway.search(account, '');
|
||||
final libraries = await gateway.libraries(account);
|
||||
// Down the first library to its first video.
|
||||
var items = [...videos, ...libraries];
|
||||
for (var depth = 0; depth < 4; depth++) {
|
||||
final video = items.where((item) => item.kind.isVideo).firstOrNull;
|
||||
if (video != null) {
|
||||
final playback = await gateway.playback(account, video.id);
|
||||
final track = playback.subtitles
|
||||
.where((track) => track.isText)
|
||||
.firstOrNull;
|
||||
final cues = track == null
|
||||
? const <SubtitleCue>[]
|
||||
: parseSubtitles(
|
||||
await gateway.subtitles(account, playback, track),
|
||||
);
|
||||
return (account: account, playback: playback, cues: cues);
|
||||
}
|
||||
items = items.isEmpty
|
||||
? items
|
||||
: await gateway.children(account, items.first.id);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
expect(found, isNotNull, reason: 'No video found on $_url');
|
||||
final (:account, :playback, :cues) = found!;
|
||||
expect(cues, isNotEmpty, reason: 'The video has no text subtitles');
|
||||
|
||||
final players = PlatformMediaPlayers();
|
||||
expect(players.available, isTrue);
|
||||
// From the middle, where a picture is unlikely to be one colour.
|
||||
final video = (await tester.runAsync(
|
||||
() => players.open(
|
||||
gateway.streamOf(account, playback),
|
||||
headers: gateway.headersOf(account),
|
||||
start: (playback.runTime ?? Duration.zero) ~/ 2,
|
||||
),
|
||||
))!;
|
||||
addTearDown(() => tester.runAsync(video.dispose));
|
||||
final picture = GlobalKey();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: RepaintBoundary(
|
||||
key: picture,
|
||||
child: SizedBox(width: 640, height: 360, child: video.view()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await video.play();
|
||||
await Future<void>.delayed(const Duration(seconds: 4));
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
final state = video.state.value;
|
||||
expect(state.error, isNull);
|
||||
expect(state.duration, greaterThan(Duration.zero));
|
||||
expect(state.position, greaterThan(const Duration(seconds: 1)));
|
||||
// The picture is drawn, not only the sound played: a frame isn't one
|
||||
// flat colour.
|
||||
final boundary =
|
||||
picture.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final frame = (await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
return (await image.toByteData())!;
|
||||
}))!;
|
||||
final lightness = [
|
||||
for (var i = 0; i < frame.lengthInBytes; i += 4)
|
||||
frame.getUint8(i) + frame.getUint8(i + 1) + frame.getUint8(i + 2),
|
||||
];
|
||||
final mean = lightness.reduce((a, b) => a + b) / lightness.length;
|
||||
final spread =
|
||||
lightness.map((value) => (value - mean).abs()).reduce((a, b) => a + b) /
|
||||
lightness.length;
|
||||
expect(spread, greaterThan(4), reason: 'The picture is one flat colour');
|
||||
|
||||
await tester.runAsync(() => video.seekTo(cues.first.start));
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 500)),
|
||||
);
|
||||
expect(
|
||||
cueAt(cues, video.state.value.position + const Duration(milliseconds: 1)),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdfrx_engine.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// The app's PDF engine for real, PDFium included, which `flutter test`
|
||||
// can't load: on a device or the desktop,
|
||||
//
|
||||
// flutter test integration_test/pdf_engine_test.dart -d linux
|
||||
//
|
||||
// on a PDF of three pages, "Hello page N" on each, with an outline of two
|
||||
// chapters.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(PdfrxEngine.initialize);
|
||||
|
||||
testWidgets('opens a PDF, reads its chapters and draws a region', (
|
||||
tester,
|
||||
) async {
|
||||
final document = await tester.runAsync(
|
||||
() => PdfrxEngine().open(_threePages()),
|
||||
);
|
||||
addTearDown(() => tester.runAsync(document!.dispose));
|
||||
|
||||
expect(document!.pageCount, 3);
|
||||
expect(await tester.runAsync(document.chapters), [
|
||||
(title: 'One', page: 1),
|
||||
(title: 'Two', page: 3),
|
||||
]);
|
||||
final png = await tester.runAsync(
|
||||
() => document.renderRegion(1, const Rect.fromLTWH(0, 0, 0.5, 0.5)),
|
||||
);
|
||||
expect(png!.sublist(0, 4), [0x89, 0x50, 0x4e, 0x47]);
|
||||
});
|
||||
|
||||
testWidgets('refuses a file that is no PDF', (tester) async {
|
||||
// runAsync reports what is thrown inside instead of passing it on.
|
||||
final refused = await tester.runAsync(() async {
|
||||
try {
|
||||
await PdfrxEngine().open(Uint8List.fromList(utf8.encode('text')));
|
||||
return false;
|
||||
} on FormatException {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
expect(refused, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('its viewer turns pages and finds text', (tester) async {
|
||||
final document = await tester.runAsync(
|
||||
() => PdfrxEngine().open(_threePages()),
|
||||
);
|
||||
final controller = PdfReaderController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 600,
|
||||
height: 800,
|
||||
child: document!.viewer(controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
Future<void> until(bool Function() test) async {
|
||||
final waited = Stopwatch()..start();
|
||||
while (!test() && waited.elapsed < const Duration(seconds: 20)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
await until(() => controller.isReady);
|
||||
expect(controller.isReady, isTrue);
|
||||
|
||||
// The page turns over an animation, whose frames the test drives: it
|
||||
// is started, then pumped until it arrives.
|
||||
unawaited(controller.goToPage(3));
|
||||
await until(() => controller.page == 3);
|
||||
expect(controller.page, 3);
|
||||
|
||||
controller.search('hello');
|
||||
await until(() => controller.matchCount == 3);
|
||||
expect(controller.matchCount, 3);
|
||||
expect(controller.pageRect(3), isNotNull);
|
||||
});
|
||||
}
|
||||
|
||||
/// A PDF of three pages, each saying "Hello page N", with an outline of
|
||||
/// chapter "One" at page 1 and "Two" at page 3.
|
||||
Uint8List _threePages() {
|
||||
String text(int page) {
|
||||
final stream = 'BT /F1 24 Tf 40 700 Td (Hello page $page) Tj ET';
|
||||
return '<< /Length ${stream.length} >>\nstream\n$stream\nendstream';
|
||||
}
|
||||
|
||||
String pageOf(int page) =>
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] '
|
||||
'/Contents ${5 + page} 0 R /Resources << /Font << /F1 9 0 R >> >> >>';
|
||||
|
||||
final objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>',
|
||||
for (final page in [1, 2, 3]) pageOf(page),
|
||||
for (final page in [1, 2, 3]) text(page),
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
'<< /Type /Outlines /First 11 0 R /Last 12 0 R /Count 2 >>',
|
||||
'<< /Title (One) /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >>',
|
||||
'<< /Title (Two) /Parent 10 0 R /Prev 11 0 R /Dest [5 0 R /Fit] >>',
|
||||
];
|
||||
final out = StringBuffer('%PDF-1.7\n');
|
||||
final offsets = <int>[];
|
||||
for (final (index, object) in objects.indexed) {
|
||||
offsets.add(out.length);
|
||||
out.write('${index + 1} 0 obj\n$object\nendobj\n');
|
||||
}
|
||||
final xref = out.length;
|
||||
out
|
||||
..write('xref\n0 ${objects.length + 1}\n0000000000 65535 f \n')
|
||||
..writeAll([
|
||||
for (final offset in offsets)
|
||||
'${offset.toString().padLeft(10, '0')} 00000 n \n',
|
||||
])
|
||||
..write(
|
||||
'trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n'
|
||||
'startxref\n$xref\n%%EOF\n',
|
||||
);
|
||||
return Uint8List.fromList(latin1.encode(out.toString()));
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:youtube_player_iframe/youtube_player_iframe.dart';
|
||||
|
||||
// Plays a real YouTube video through the app's player, on a device where
|
||||
// videos play inside the app, online:
|
||||
//
|
||||
// flutter test integration_test/youtube_player_test.dart -d <device>
|
||||
//
|
||||
// YouTube refuses embeds that don't say where they are embedded (its error
|
||||
// 153), which shows only in a real web view; such a player may also never
|
||||
// start, without an error. So this passes only once the video plays.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('a YouTube video plays inside the app', (tester) async {
|
||||
final videos = IframeYoutubeVideos();
|
||||
expect(videos.playInApp, isTrue, reason: 'Run it where videos play.');
|
||||
// YouTube's own sample video for its embedded player.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(child: videos.player((id: 'M7lc1UVf-VE', start: 10))),
|
||||
),
|
||||
),
|
||||
);
|
||||
final controller = tester
|
||||
.widget<YoutubePlayer>(find.byType(YoutubePlayer))
|
||||
.controller;
|
||||
|
||||
final waited = Stopwatch()..start();
|
||||
while (waited.elapsed < const Duration(minutes: 1)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
final value = controller.value;
|
||||
expect(value.error, YoutubeError.none, reason: 'YouTube refused it.');
|
||||
if (value.playerState == PlayerState.playing) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
fail(
|
||||
'The video did not start within a minute; the player was '
|
||||
'${controller.value.playerState.name}.',
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue