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
3 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,43 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart';
final markdown = Uint8List.fromList(utf8.encode('# Spanish\n' * 100));
final pdf = Uint8List.fromList(utf8.encode('%PDF-1.7 ' * 100));
final files = <ExportFile>[
(path: 'Spanish.md', bytes: markdown),
(path: 'files/pdfs/Grammar.PDF', bytes: pdf),
];
void main() {
test('packs the files in their order: what is compressed already as it '
'is, the rest deflated', () {
final zip = ZipDecoder().decodeBytes(zipExport(files));
expect(
[for (final file in zip.files) file.name],
[
'Spanish.md',
'files/pdfs/Grammar.PDF',
],
);
expect(zip.files[0].content, markdown);
expect(zip.files[0].compression, CompressionType.deflate);
expect(zip.files[1].content, pdf);
expect(zip.files[1].compression, CompressionType.none);
});
test('packs the same on an isolate of its own', () async {
final zip = ZipDecoder().decodeBytes(
await IsolateExportArchiver().zip(files),
);
expect(zip.files[0].content, markdown);
expect(zip.files[1].content, pdf);
});
}

View file

@ -0,0 +1,350 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/export/data/studyset_markdown.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.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/license_link.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
void main() {
late FakeStudysetGateway studysetGateway;
late StudysetRepository studysets;
late CardRepository cards;
late NoteRepository notes;
late LearningPathRepository paths;
late AssetRepository assets;
late PdfSourceRepository pdfs;
late StudysetMarkdownExporter exporter;
setUp(() {
studysetGateway = FakeStudysetGateway();
studysets = StudysetRepository(studysetGateway);
cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway());
paths = LearningPathRepository(FakeLearningPathGateway());
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
exporter = StudysetMarkdownExporter(
studysets,
cards,
notes,
paths,
pdfs,
assets.fileOf,
);
});
tearDown(() async {
await studysets.dispose();
await cards.dispose();
await notes.dispose();
await paths.dispose();
await pdfs.dispose();
await assets.dispose();
});
final texts = (
richText: (
sound: 'Sound',
video: 'YouTube video',
spoiler: 'Spoiler',
calloutName: (Object? kind) => 'Note',
unavailable: 'not available on this device',
),
flashcards: 'Flashcards',
notes: 'Notes',
paths: 'Learning paths',
files: 'Files',
bibliography: 'Bibliography',
question: 'Q',
answer: 'A',
hint: 'Hint',
explanation: 'Explanation',
untitledNote: 'Untitled note',
untitledStep: 'Untitled step',
stepKind: (StepKind kind) => switch (kind) {
StepKind.info => 'Info',
StepKind.singleChoice => 'Single choice',
_ => kind.value,
},
ifRight: 'If right',
ifWrong: 'If wrong',
sources: 'Sources',
);
Future<StudysetMarkdown> export(String id) =>
exporter.export(id, texts: texts, style: CitationStyle.apa);
RichContent blocks(List<Map<String, Object?>> children) => RichContent({
'document': {'type': 'page', 'children': children},
});
test('writes the studyset, then its flashcards, notes, paths and '
'bibliography, then its sub-studysets a level deeper', () async {
const source = Source(authors: 'Ana López', year: '2024', title: 'Hola');
final spanish = await studysets.create(
StudysetContent(
title: 'Spanish vocab',
secondaryTitle: 'A1',
description: RichContent.plain('Words for a first trip.'),
license: 'CC-BY-4.0',
licenseHolder: 'Ana',
licenseYear: 2026,
bibliography: const [source],
),
);
await cards.create(
spanish,
CardContent(
front: RichContent.plain('hola'),
back: RichContent.plain('hello'),
hints: [RichContent.plain('starts with h')],
),
);
await cards.create(
spanish,
CardContent(
type: CardType.multipleChoice,
front: RichContent.plain('Which means goodbye?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: RichContent.plain('adiós'), correct: true),
CardAnswer(content: RichContent.plain('hola')),
],
),
);
await notes.create(
spanish,
NoteContent(
title: 'Pronunciation',
body: RichContent.plain('The h is silent.'),
),
);
final basics = await paths.createPath(
spanish,
LearningPathContent.titled('Basics'),
);
await paths.createStep(
basics,
StepKind.info,
StepContent.empty(StepKind.info)
.withField(StepField.title, 'Saying hello')
.withField(StepField.body, RichContent.plain('Hola greets.')),
);
await paths.createStep(
basics,
StepKind.singleChoice,
StepContent.empty(StepKind.singleChoice)
.withField(StepField.title, 'Which one means goodbye?')
.withField(
StepField.exercise,
const SingleChoice(options: ['hola', 'adiós'], correct: 1),
)
.withField(StepField.rightFeedback, RichContent.plain('¡Sí!')),
);
final food = await studysets.create(
StudysetContent.titled('Food'),
parentId: spanish,
);
await cards.create(
food,
CardContent(
front: RichContent.plain('pan'),
back: RichContent.plain('bread'),
),
);
final citation = formatCitation(
source,
CitationStyle.apa,
).map((run) => run.italic ? '*${run.text}*' : run.text).join();
final exported = await export(spanish);
expect(exported.markdown, '''
# Spanish vocab
*A1*
Words for a first trip.
© 2026 Ana · [CC-BY-4.0](${licenseUrl('CC-BY-4.0')})
## Flashcards
- **Q:** hola
- **A:** hello
- **Hint:** starts with h
- **Q:** Which means goodbye?
- [x] adiós
- [ ] hola
## Notes
### Pronunciation
The h is silent.
## Learning paths
### Basics
#### 1. Saying hello (Info)
Hola greets.
#### 2. Which one means goodbye? (Single choice)
- [ ] hola
- [x] adiós
**If right:** ¡Sí!
## Bibliography
- $citation
## Food
### Flashcards
- **Q:** pan
- **A:** bread
''');
expect(exported.files, isEmpty);
expect(exported.missingFiles, 0);
});
test("brings the studyset's files along: its PDFs under their names, the "
'images in its content by their kind', () async {
final spanish = await studysets.create(StudysetContent.titled('Spanish'));
final png = Uint8List.fromList([137, 80, 78, 71, 1, 2, 3]);
final cat = await assets.add(
png,
fileName: 'cat.png',
mimeType: 'image/png',
);
await cards.create(
spanish,
CardContent(
front: blocks([
{
'type': 'image',
'data': {'url': assetUrl(cat)},
},
]),
back: RichContent.plain('gato'),
),
);
final kana = Uint8List.fromList(utf8.encode('%PDF-1.7 kana'));
final hiragana = Uint8List.fromList(utf8.encode('%PDF-1.7 hiragana'));
await pdfs.import(spanish, bytes: kana, fileName: 'kana.pdf');
await pdfs.import(spanish, bytes: hiragana, fileName: 'kana.pdf');
final exported = await export(spanish);
final image = exported.files.singleWhere(
(file) => file.path.startsWith('files/images/'),
);
expect(image.path, endsWith('.png'));
expect(image.bytes, png);
expect(exported.markdown, contains('- **Q:** ![](${image.path})'));
expect(
{
for (final file in exported.files)
if (file.path.startsWith('files/pdfs/')) file.path: file.bytes,
},
{'files/pdfs/kana.pdf': kana, 'files/pdfs/kana-2.pdf': hiragana},
);
expect(
exported.markdown,
contains(
'## Files\n\n- [kana](files/pdfs/kana.pdf)\n'
'- [kana](files/pdfs/kana-2.pdf)\n',
),
);
});
test('two studysets inside each other (moved so on two devices offline) '
'are written once each, and the export ends', () async {
studysetGateway
..receiveRemoteChange(
'a',
encodeStudysetPayload(StudysetContent.titled('Grammar')),
parentId: 'b',
)
..receiveRemoteChange(
'b',
encodeStudysetPayload(StudysetContent.titled('Particles')),
parentId: 'a',
);
final exported = await export('a');
expect('# Grammar'.allMatches(exported.markdown), hasLength(1));
expect('## Particles'.allMatches(exported.markdown), hasLength(1));
});
test("a file's link keeps working whatever its name: what would end a "
'link is encoded, the file keeps its name', () async {
final spanish = await studysets.create(StudysetContent.titled('Spanish'));
final pdf = Uint8List.fromList(utf8.encode('%PDF-1.7 c#'));
await pdfs.import(spanish, bytes: pdf, fileName: 'C# (100%).pdf');
final exported = await export(spanish);
expect(exported.files.single.path, 'files/pdfs/C#_(100%).pdf');
expect(
exported.markdown,
contains('](files/pdfs/C%23_%28100%25%29.pdf)'),
);
});
test("a file this device can't have now is named, and counted", () async {
final spanish = await studysets.create(StudysetContent.titled('Spanish'));
await cards.create(
spanish,
CardContent(
front: blocks([
{
'type': 'image',
'data': {'url': assetUrl('gone')},
},
]),
back: RichContent.plain('gato'),
),
);
final exported = await export(spanish);
expect(exported.files, isEmpty);
expect(exported.missingFiles, 1);
expect(exported.markdown, contains('(not available on this device)'));
});
}

View file

@ -0,0 +1,307 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.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/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/export/widget/export_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.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_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_file_saver.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeAssetGateway assetGateway;
late MemoryAssetFileCache cache;
late FakeFileSaver saver;
late FakeReviewGateway reviewGateway;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'spanish',
encodeStudysetPayload(StudysetContent.titled('Spanish vocab')),
);
cardGateway = FakeCardGateway();
assetGateway = FakeAssetGateway();
cache = MemoryAssetFileCache();
saver = FakeFileSaver();
reviewGateway = FakeReviewGateway();
});
Future<String> card(CardContent content) {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
return cards.create('spanish', content);
}
/// Opens the studyset's Export content, and, unless [choose] is null,
/// chooses it (the format's name, as the sheet shows it), sets
/// [withProgress] and exports.
Future<void> exportFromMenu(
WidgetTester tester, {
String? choose = 'Markdown',
bool withProgress = true,
}) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
assetGateway: assetGateway,
assetFiles: cache,
fileSaver: saver,
reviewGateway: reviewGateway,
),
location: RoutePaths.studyset('spanish'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Export content'));
await tester.pumpAndSettle();
if (choose == null) {
return;
}
await tester.tap(find.text(choose));
await tester.pumpAndSettle();
if (!withProgress) {
await tester.tap(find.text('With my progress'));
await tester.pumpAndSettle();
}
await tester.tap(find.widgetWithText(FilledButton, 'Export'));
await tester.pumpAndSettle();
}
/// The archive saved, by its files' paths.
Map<String, List<int>> savedArchive() => {
for (final file in ZipDecoder().decodeBytes(saver.saved.single.bytes).files)
file.name: file.content,
};
testWidgets('Export content offers an archive first, with the progress '
'and Markdown below; the progress is only asked about for the '
'archive', (tester) async {
await exportFromMenu(tester, choose: null);
expect(
tester
.widget<RadioGroup<ExportFormat>>(
find.byType(RadioGroup<ExportFormat>),
)
.groupValue,
ExportFormat.archive,
);
expect(
tester
.widget<CheckboxListTile>(
find.widgetWithText(CheckboxListTile, 'With my progress'),
)
.value,
isTrue,
);
await tester.tap(find.text('Markdown'));
await tester.pumpAndSettle();
expect(find.text('With my progress'), findsNothing);
});
testWidgets('an archive keeps the studyset with its reviews as a zip '
'named after it, which the import takes back', (tester) async {
final hola = await card(
CardContent(
front: RichContent.plain('hola'),
back: RichContent.plain('hello'),
),
);
await ReviewRepository(reviewGateway).record(
cardId: hola,
rating: Rating.good,
reviewedAt: DateTime.utc(2026, 10),
duration: const Duration(seconds: 2),
);
await exportFromMenu(tester, choose: 'Archive');
final saved = saver.saved.single;
expect(saved.name, 'Spanish vocab.zip');
expect(saved.mimeType, 'application/zip');
final files = savedArchive();
expect(
files.keys,
containsAll(['archive.json', 'studyset.json', 'cards/$hola.json']),
);
expect(utf8.decode(files['reviews.json']!), contains('"card": "$hola"'));
expect(find.text('Exported as an archive (.zip).'), findsOneWidget);
});
testWidgets('without the progress, an archive holds no reviews', (
tester,
) async {
final hola = await card(
CardContent(
front: RichContent.plain('hola'),
back: RichContent.plain('hello'),
),
);
await ReviewRepository(reviewGateway).record(
cardId: hola,
rating: Rating.good,
reviewedAt: DateTime.utc(2026, 10),
duration: const Duration(seconds: 2),
);
await exportFromMenu(tester, choose: 'Archive', withProgress: false);
expect(savedArchive().keys, isNot(contains('reviews.json')));
});
testWidgets('an archive missing a file this device cannot have now is '
'not kept, and says so', (tester) async {
final elsewhere = await assetGateway.insert(
sha256: 'f' * 64,
mimeType: 'image/png',
byteSize: 3,
payload: encodeAssetPayload(fileName: 'cat.png'),
);
await card(
CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': assetUrl(elsewhere)},
},
],
},
}),
back: RichContent.plain('gato'),
),
);
await exportFromMenu(tester, choose: 'Archive');
expect(saver.saved, isEmpty);
expect(
find.text(
"1 file isn't on this device and couldn't be downloaded. Connect "
'to the internet and try again.',
),
findsOneWidget,
);
});
testWidgets('as Markdown, it keeps the studyset as a Markdown file, named '
'after it', (tester) async {
await card(
CardContent(
front: RichContent.plain('hola'),
back: RichContent.plain('hello'),
),
);
await exportFromMenu(tester);
final saved = saver.saved.single;
expect(saved.name, 'Spanish vocab.md');
expect(saved.mimeType, 'text/markdown');
expect(
utf8.decode(saved.bytes),
startsWith(
'# Spanish vocab\n\n## Flashcards\n\n'
'- **Q:** hola\n - **A:** hello\n',
),
);
expect(find.text('Exported as Markdown.'), findsOneWidget);
});
testWidgets('with files, it keeps a zip of the document and the files it '
'links to', (tester) async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
cache,
);
addTearDown(assets.dispose);
final png = Uint8List.fromList([137, 80, 78, 71, 1, 2, 3]);
final cat = await assets.add(
png,
fileName: 'cat.png',
mimeType: 'image/png',
);
await card(
CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': assetUrl(cat)},
},
],
},
}),
back: RichContent.plain('gato'),
),
);
await exportFromMenu(tester);
final saved = saver.saved.single;
expect(saved.name, 'Spanish vocab.zip');
final zip = ZipDecoder().decodeBytes(saved.bytes);
final image = zip.files.singleWhere(
(file) => file.name.startsWith('files/images/'),
);
expect(image.content, png);
expect(
utf8.decode(zip.findFile('Spanish vocab.md')!.content),
contains('![](${image.name})'),
);
expect(
find.text('Exported as Markdown with 1 file (.zip).'),
findsOneWidget,
);
});
testWidgets('cancelled where to keep it, it says nothing more', (
tester,
) async {
saver.cancel = true;
await exportFromMenu(tester);
expect(saver.saved, isEmpty);
expect(find.text('Exporting…'), findsNothing);
expect(find.textContaining('Exported'), findsNothing);
});
}