chore: Halkyon Learn moves into apps/learn

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 0 additions and 0 deletions

View file

@ -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, '静か');
});
}

View file

@ -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,
);
});
}

View file

@ -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()));
}

View file

@ -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}.',
);
});
}