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
5 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,227 @@
|
|||
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/content/published_ref.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_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.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/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
||||
import '../../support/fake_asset_gateway.dart';
|
||||
import '../../support/fake_asset_server_gateway.dart';
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_explore_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/fake_tag_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
/// The JSON a synced [payload] holds.
|
||||
Map<String, Object?> json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
/// Release [version] of Japanese with [cards] (by id: front and back).
|
||||
PublishedRelease japanese(int version, Map<String, (String, String)> cards) =>
|
||||
PublishedRelease(
|
||||
publicId: 'japanese',
|
||||
version: version,
|
||||
items: {
|
||||
'studyset.json': {
|
||||
'id': 'root',
|
||||
'payload': json(
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
),
|
||||
},
|
||||
for (final (i, MapEntry(key: id, value: (front, back)))
|
||||
in cards.entries.indexed)
|
||||
'cards/$id.json': {
|
||||
'id': id,
|
||||
'position': 'a$i',
|
||||
'payload': json(
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent.plain(front),
|
||||
back: RichContent.plain(back),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
ExploreStudyset listed(int version) => ExploreStudyset(
|
||||
publicId: 'japanese',
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
handle: 'alice',
|
||||
version: version,
|
||||
releasedAt: DateTime.utc(2026, 10, 9),
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
license: 'CC-BY-SA-4.0',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysetGateway;
|
||||
late FakeCardGateway cardGateway;
|
||||
late String root;
|
||||
|
||||
setUp(() async {
|
||||
studysetGateway = FakeStudysetGateway();
|
||||
cardGateway = FakeCardGateway();
|
||||
final studysets = StudysetRepository(studysetGateway);
|
||||
final cards = CardRepository(cardGateway);
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
// The person added release 1 to their library before.
|
||||
root =
|
||||
await ReleaseCopier(
|
||||
studysets,
|
||||
cards,
|
||||
notes,
|
||||
paths,
|
||||
PdfSourceRepository(FakePdfSourceGateway(), assets),
|
||||
TagRepository(FakeTagGateway()),
|
||||
assets,
|
||||
).copy(
|
||||
japanese(1, {'cat': ('猫', 'cat')}),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: (_) async => Uint8List(0),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> showCopy(WidgetTester tester, FakeExploreGateway explore) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysetGateway,
|
||||
cardGateway: cardGateway,
|
||||
exploreGateway: explore,
|
||||
),
|
||||
location: RoutePaths.studyset(root),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a newer release is offered on the copy, with what it brings, '
|
||||
'and taken at a tap', (tester) async {
|
||||
await showCopy(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [listed(2)],
|
||||
releases: [
|
||||
japanese(1, {'cat': ('猫', 'cat')}),
|
||||
japanese(2, {'cat': ('猫', 'a cat'), 'dog': ('犬', 'dog')}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('v2 is out: 1 changed, 1 new.'), findsOneWidget);
|
||||
await tester.tap(find.text('Update'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Updated to v2.'), findsOneWidget);
|
||||
expect(find.text('Update'), findsNothing);
|
||||
final cards = await CardRepository(cardGateway).cardsOf(root);
|
||||
expect(
|
||||
[for (final card in cards) card.content.back.plainText],
|
||||
[
|
||||
'a cat',
|
||||
'dog',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('nothing is offered while the copy holds the latest', (
|
||||
tester,
|
||||
) async {
|
||||
await showCopy(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [listed(1)],
|
||||
releases: [
|
||||
japanese(1, {'cat': ('猫', 'cat')}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Update'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a withdrawn studyset offers nothing', (tester) async {
|
||||
await showCopy(tester, FakeExploreGateway());
|
||||
|
||||
expect(find.text('Update'), findsNothing);
|
||||
});
|
||||
|
||||
test('the card browser finds the cards the author withdrew', () {
|
||||
BrowsedCard browsed({required bool withdrawn}) => (
|
||||
card: Card(
|
||||
id: 'card',
|
||||
position: 'a0',
|
||||
content: CardContent(
|
||||
front: RichContent.plain('去'),
|
||||
back: RichContent.plain('gone'),
|
||||
published: PublishedRef(
|
||||
publication: 'japanese',
|
||||
item: 'gone',
|
||||
version: 2,
|
||||
withdrawn: withdrawn,
|
||||
),
|
||||
),
|
||||
heads: const {'revision'},
|
||||
),
|
||||
studysetId: 'japanese',
|
||||
tags: const [],
|
||||
schedule: null,
|
||||
);
|
||||
final now = DateTime.utc(2026, 10, 9);
|
||||
|
||||
expect(
|
||||
isInState(
|
||||
browsed(withdrawn: true),
|
||||
CardStatus.withdrawn,
|
||||
now: now,
|
||||
startOfToday: now,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isInState(
|
||||
browsed(withdrawn: false),
|
||||
CardStatus.withdrawn,
|
||||
now: now,
|
||||
startOfToday: now,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,611 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
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/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.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/exam_plan.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 '../../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/fake_tag_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
|
||||
/// The JSON a synced [payload] holds.
|
||||
Map<String, Object?> json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
CardContent card(String front, String back) =>
|
||||
CardContent(front: RichContent.plain(front), back: RichContent.plain(back));
|
||||
|
||||
/// [json] with the keys of every map in it sorted, as the server writes a
|
||||
/// release's files.
|
||||
Map<String, Object?> sortedKeys(Map<String, Object?> json) => {
|
||||
for (final key in json.keys.toList()..sort()) key: _sorted(json[key]),
|
||||
};
|
||||
|
||||
Object? _sorted(Object? json) => switch (json) {
|
||||
final Map<String, Object?> map => sortedKeys(map),
|
||||
final List<Object?> list => [for (final value in list) _sorted(value)],
|
||||
_ => json,
|
||||
};
|
||||
|
||||
final book = Uint8List.fromList(utf8.encode('%PDF-1.7 a book'));
|
||||
final bookHash = sha256.convert(book).toString();
|
||||
const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
|
||||
|
||||
/// Release [version] of a Japanese studyset, described as [description],
|
||||
/// planning for [examPlans], with [cards] (by id: front, back and tags) in
|
||||
/// it, a Kanji studyset in it holding [kanjiCards], a Grammar studyset
|
||||
/// [withGrammar], and a PDF titled [bookTitle]; its files' keys sorted, as
|
||||
/// the server writes them.
|
||||
PublishedRelease japanese(
|
||||
int version, {
|
||||
String description = 'From N5 on.',
|
||||
List<ExamPlan> examPlans = const [],
|
||||
Map<String, (String, String, List<String>)> cards = const {},
|
||||
Map<String, (String, String, List<String>)> kanjiCards = const {},
|
||||
bool withKanji = true,
|
||||
bool withGrammar = false,
|
||||
String bookTitle = 'A book',
|
||||
}) {
|
||||
Map<String, Object?> cardFile(
|
||||
String id,
|
||||
(String, String, List<String>) card,
|
||||
int position,
|
||||
) => {
|
||||
'id': id,
|
||||
'position': 'a$position',
|
||||
'tags': card.$3,
|
||||
'payload': json(
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent.plain(card.$1),
|
||||
back: RichContent.plain(card.$2),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
final items = <String, Map<String, Object?>>{
|
||||
'studyset.json': {
|
||||
'id': 'japanese',
|
||||
'payload': json(
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.plain(description),
|
||||
license: 'CC-BY-SA-4.0',
|
||||
examPlans: examPlans,
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
for (final (i, MapEntry(key: id, value: card)) in cards.entries.indexed)
|
||||
'cards/$id.json': cardFile(id, card, i),
|
||||
if (withKanji) ...{
|
||||
'kanji/studyset.json': {
|
||||
'id': 'kanji',
|
||||
'position': 'a1',
|
||||
'payload': json(
|
||||
encodeStudysetPayload(StudysetContent.titled('Kanji')),
|
||||
),
|
||||
},
|
||||
for (final (i, MapEntry(key: id, value: card))
|
||||
in kanjiCards.entries.indexed)
|
||||
'kanji/cards/$id.json': cardFile(id, card, i),
|
||||
},
|
||||
if (withGrammar)
|
||||
'grammar/studyset.json': {
|
||||
'id': 'grammar',
|
||||
'position': 'a2',
|
||||
'payload': json(
|
||||
encodeStudysetPayload(StudysetContent.titled('Grammar')),
|
||||
),
|
||||
},
|
||||
'pdfs/book.json': {
|
||||
'id': 'book',
|
||||
'position': 'a0',
|
||||
'payload': json(
|
||||
encodePdfSourcePayload(
|
||||
PdfSourceContent(
|
||||
title: bookTitle,
|
||||
fileName: 'book.pdf',
|
||||
assetId: bookAsset,
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
'files.json': {
|
||||
bookAsset: {
|
||||
'sha256': bookHash,
|
||||
'mimeType': 'application/pdf',
|
||||
'size': book.length,
|
||||
'payload': {'schemaVersion': 1, 'fileName': 'book.pdf'},
|
||||
},
|
||||
},
|
||||
};
|
||||
return PublishedRelease(
|
||||
publicId: 'publication-1',
|
||||
version: version,
|
||||
items: {
|
||||
for (final MapEntry(key: path, value: item) in items.entries)
|
||||
path: sortedKeys(item),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
late PdfSourceRepository pdfs;
|
||||
late ReleaseCopier copier;
|
||||
late CopyUpdater updater;
|
||||
late List<String> downloaded;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
copier = ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets);
|
||||
updater = CopyUpdater(studysets, cards, notes, paths, pdfs, tags, assets);
|
||||
downloaded = [];
|
||||
});
|
||||
|
||||
Future<Uint8List> fileOf(String sha256) async {
|
||||
downloaded.add(sha256);
|
||||
return book;
|
||||
}
|
||||
|
||||
Future<String> copyOf(PublishedRelease release) => copier.copy(
|
||||
release,
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
Future<Card> cardFrom(String rootId, String item) async {
|
||||
for (final studyset in await studysets.all()) {
|
||||
for (final card in await cards.cardsOf(studyset.id)) {
|
||||
if (card.content.published?.item == item) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw StateError('No card from $item.');
|
||||
}
|
||||
|
||||
test(
|
||||
'counts what a release would bring, from the one the copy took',
|
||||
() async {
|
||||
final v1 = japanese(
|
||||
1,
|
||||
cards: {
|
||||
'cat': ('猫', 'cat', []),
|
||||
'dog': ('犬', 'dog', []),
|
||||
'gone': ('去', 'gone', []),
|
||||
},
|
||||
);
|
||||
final root = await copyOf(v1);
|
||||
|
||||
final update = await updater.plan(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
cards: {
|
||||
'cat': ('猫', 'a cat', []),
|
||||
'dog': ('犬', 'dog', []),
|
||||
'new': ('新', 'new', []),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
update,
|
||||
const CopyUpdate(version: 2, changed: 1, added: 1, withdrawn: 1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('what only the release changed comes in, what the learner changed '
|
||||
'stays, and a side both changed is a conflict to settle', () async {
|
||||
final v1 = japanese(
|
||||
1,
|
||||
cards: {
|
||||
'cat': ('猫', 'cat', []),
|
||||
'dog': ('犬', 'dog', []),
|
||||
'fish': ('魚', 'fish', []),
|
||||
},
|
||||
);
|
||||
final root = await copyOf(v1);
|
||||
// The learner writes the dog's front in kana, and the fish's back.
|
||||
final dog = await cardFrom(root, 'dog');
|
||||
await cards.save(
|
||||
dog,
|
||||
dog.content.withField(CardField.front, RichContent.plain('いぬ')),
|
||||
);
|
||||
final fish = await cardFrom(root, 'fish');
|
||||
await cards.save(
|
||||
fish,
|
||||
fish.content.withField(CardField.back, RichContent.plain('a fish')),
|
||||
);
|
||||
|
||||
final toSettle = await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
cards: {
|
||||
'cat': ('猫', 'a cat', []),
|
||||
'dog': ('犬', 'a dog', []),
|
||||
'fish': ('魚', 'the fish', []),
|
||||
},
|
||||
),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
expect(toSettle, 1);
|
||||
final cat = await cardFrom(root, 'cat');
|
||||
expect(cat.content.back.plainText, 'a cat');
|
||||
expect(cat.content.published?.version, 2);
|
||||
final dogNow = await cardFrom(root, 'dog');
|
||||
expect(dogNow.content.front.plainText, 'いぬ');
|
||||
expect(dogNow.content.back.plainText, 'a dog');
|
||||
expect(dogNow.hasConflict, isFalse);
|
||||
expect((await cardFrom(root, 'fish')).conflicts.keys, [CardField.back]);
|
||||
});
|
||||
|
||||
test('new items come in where they belong; what the author withdrew '
|
||||
'stays, marked; what the learner deleted stays deleted', () async {
|
||||
final v1 = japanese(
|
||||
1,
|
||||
cards: {'cat': ('猫', 'cat', []), 'gone': ('去', 'gone', [])},
|
||||
kanjiCards: {'one': ('一', 'one', [])},
|
||||
);
|
||||
final root = await copyOf(v1);
|
||||
final kanji = (await studysets.all()).singleWhere(
|
||||
(studyset) => studyset.parentId == root,
|
||||
);
|
||||
await cards.delete(await cardFrom(root, 'cat'));
|
||||
// The learner deleted Kanji: what comes new in it doesn't come.
|
||||
await cards.delete(await cardFrom(root, 'one'));
|
||||
await studysets.delete(kanji.id);
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
cards: {'cat': ('猫', 'a cat', []), 'new': ('新', 'new', [])},
|
||||
kanjiCards: {'one': ('一', 'one', []), 'two': ('二', 'two', [])},
|
||||
),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final inRoot = await cards.cardsOf(root);
|
||||
expect(
|
||||
[for (final card in inRoot) card.content.published?.item],
|
||||
[
|
||||
'gone',
|
||||
'new',
|
||||
],
|
||||
);
|
||||
final gone = inRoot.first;
|
||||
expect(gone.content.published?.withdrawn, isTrue);
|
||||
expect(gone.content.published?.version, 2);
|
||||
expect(gone.content.back.plainText, 'gone');
|
||||
expect(
|
||||
(await studysets.all()).where((studyset) => studyset.parentId == root),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('a new studyset comes with what is in it', () async {
|
||||
final v1 = japanese(1, withKanji: false);
|
||||
final root = await copyOf(v1);
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(2, kanjiCards: {'one': ('一', 'one', [])}),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final kanji = (await studysets.all()).singleWhere(
|
||||
(studyset) => studyset.parentId == root,
|
||||
);
|
||||
expect(kanji.content.title, 'Kanji');
|
||||
expect(kanji.content.published?.item, 'kanji');
|
||||
expect((await cards.cardsOf(kanji.id)).single.content.front.plainText, '一');
|
||||
});
|
||||
|
||||
test("exam plans the release brings name the copy's studysets; the exam "
|
||||
'the learner prepares for stays theirs', () async {
|
||||
final v1 = japanese(1);
|
||||
final root = await copyOf(v1);
|
||||
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
|
||||
final exam = ExamChoice(plan: 'auto:jlpt:1', date: DateTime(2026, 12, 6));
|
||||
await studysets.updateContent(root, copy.content.withExam(exam));
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
examPlans: const [
|
||||
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: ['kanji']),
|
||||
],
|
||||
),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final all = await studysets.all();
|
||||
final now = all.singleWhere((s) => s.id == root);
|
||||
final kanji = all.singleWhere((s) => s.parentId == root);
|
||||
expect(now.content.examPlans, [
|
||||
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: [kanji.id]),
|
||||
]);
|
||||
expect(now.content.exam, exam);
|
||||
});
|
||||
|
||||
test("the author's later changes to an exam plan come in, a topic new in "
|
||||
"the release named by the copy's studyset", () async {
|
||||
final v1 = japanese(
|
||||
1,
|
||||
examPlans: const [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji test',
|
||||
topics: ['kanji'],
|
||||
tags: ['JLPT-N5'],
|
||||
),
|
||||
],
|
||||
);
|
||||
final root = await copyOf(v1);
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
withGrammar: true,
|
||||
examPlans: const [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji and grammar test',
|
||||
topics: ['kanji', 'grammar'],
|
||||
tags: ['JLPT-N5'],
|
||||
),
|
||||
],
|
||||
),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final all = await studysets.all();
|
||||
String titled(String title) =>
|
||||
all.singleWhere((s) => s.content.title == title).id;
|
||||
expect(all.singleWhere((s) => s.id == root).content.examPlans, [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji and grammar test',
|
||||
topics: [titled('Kanji'), titled('Grammar')],
|
||||
tags: const ['JLPT-N5'],
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test("the learner's own change to an exam plan stays theirs", () async {
|
||||
const plan = ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji test',
|
||||
topics: ['kanji'],
|
||||
tags: ['JLPT-N5'],
|
||||
);
|
||||
final v1 = japanese(1, examPlans: const [plan]);
|
||||
final root = await copyOf(v1);
|
||||
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
|
||||
final mine = ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'My kanji test',
|
||||
topics: copy.content.examPlans.single.topics,
|
||||
tags: const ['JLPT-N5'],
|
||||
);
|
||||
await studysets.updateContent(root, copy.content.withExamPlans([mine]));
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
description: 'From N5 to N3.',
|
||||
examPlans: const [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji exam',
|
||||
topics: ['kanji'],
|
||||
tags: ['JLPT-N5'],
|
||||
),
|
||||
],
|
||||
),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final now = (await studysets.all()).singleWhere((s) => s.id == root);
|
||||
expect(now.content.examPlans, [mine]);
|
||||
expect(now.content.description.plainText, 'From N5 to N3.');
|
||||
});
|
||||
|
||||
test("a studyset's or PDF's fields the learner changed stay theirs; the "
|
||||
"copy says it holds the newer release; files it has aren't fetched "
|
||||
'again', () async {
|
||||
final v1 = japanese(1);
|
||||
final root = await copyOf(v1);
|
||||
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
|
||||
await studysets.updateContent(
|
||||
root,
|
||||
copy.content.copyWith(title: 'My Japanese'),
|
||||
);
|
||||
final pdf = (await pdfs.sourcesOf(root)).single;
|
||||
await pdfs.setDeadline(
|
||||
pdf.id,
|
||||
const ReadingDeadline(
|
||||
targetPage: 10,
|
||||
due: (year: 2026, month: 11, day: 1),
|
||||
),
|
||||
);
|
||||
downloaded.clear();
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(2, description: 'From N5 to N3.', bookTitle: 'The book'),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final now = (await studysets.all()).singleWhere((s) => s.id == root);
|
||||
expect(now.content.title, 'My Japanese');
|
||||
expect(now.content.description.plainText, 'From N5 to N3.');
|
||||
expect(now.content.published?.version, 2);
|
||||
expect(now.content.publishedAt, 'halkyon.eu/@alice/japanese');
|
||||
final book = (await pdfs.sourcesOf(root)).single;
|
||||
expect(book.content.title, 'The book');
|
||||
expect(book.content.deadline?.targetPage, 10);
|
||||
expect(downloaded, isEmpty);
|
||||
});
|
||||
|
||||
test("the release's tags come and go; the learner's own stay", () async {
|
||||
final v1 = japanese(
|
||||
1,
|
||||
cards: {
|
||||
'cat': ('猫', 'cat', ['Noun', 'N5']),
|
||||
},
|
||||
);
|
||||
final root = await copyOf(v1);
|
||||
final cat = await cardFrom(root, 'cat');
|
||||
final mine = await tags.create('Animals');
|
||||
final tagging = await tags.read();
|
||||
await tags.setTagsOf(cat.id, {
|
||||
...{for (final tag in tagging.of(cat.id)) tag.id},
|
||||
mine,
|
||||
});
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
cards: {
|
||||
'cat': ('猫', 'cat', ['Noun', 'JLPT-N5']),
|
||||
},
|
||||
),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final tag in (await tags.read()).of(cat.id)) tag.name]..sort(),
|
||||
['Animals', 'JLPT-N5', 'Noun'],
|
||||
);
|
||||
});
|
||||
|
||||
test("what is tied to a page of a PDF stays tied to the copy's PDF when "
|
||||
'the release changes it, and what a release ties to a PDF it brings '
|
||||
'is tied to the one made of it', () async {
|
||||
PublishedRelease release(int version) {
|
||||
final base = japanese(version);
|
||||
Map<String, Object?> page(String id, String back, String pdf) =>
|
||||
sortedKeys({
|
||||
'id': id,
|
||||
'position': 'a5',
|
||||
'tags': const <String>[],
|
||||
'payload': json(
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent.plain(id),
|
||||
back: RichContent.plain(back),
|
||||
source: (pdfId: pdf, page: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
return PublishedRelease(
|
||||
publicId: base.publicId,
|
||||
version: version,
|
||||
items: {
|
||||
...base.items,
|
||||
'cards/page.json': page(
|
||||
'page',
|
||||
version == 1 ? 'is' : 'is, politely',
|
||||
'book',
|
||||
),
|
||||
if (version > 1) ...{
|
||||
'pdfs/workbook.json': sortedKeys({
|
||||
'id': 'workbook',
|
||||
'position': 'a1',
|
||||
'payload': json(
|
||||
encodePdfSourcePayload(
|
||||
const PdfSourceContent(
|
||||
title: 'Workbook',
|
||||
fileName: 'workbook.pdf',
|
||||
assetId: bookAsset,
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
'cards/drill.json': page('drill', 'a drill', 'workbook'),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final v1 = release(1);
|
||||
final root = await copyOf(v1);
|
||||
await updater.take(root, v1, release(2), fileOf: fileOf);
|
||||
|
||||
final copied = await pdfs.sourcesOf(root);
|
||||
String pdfTitled(String title) =>
|
||||
copied.singleWhere((pdf) => pdf.content.title == title).id;
|
||||
final page = await cardFrom(root, 'page');
|
||||
expect(page.content.back.plainText, 'is, politely');
|
||||
expect(page.content.source, (pdfId: pdfTitled('A book'), page: 3));
|
||||
expect(
|
||||
(await cardFrom(root, 'drill')).content.source,
|
||||
(pdfId: pdfTitled('Workbook'), page: 3),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.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_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.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/studyset/widget/studyset_screen.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_explore_gateway.dart';
|
||||
import '../../support/fake_profile_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
/// A published studyset as Explore lists it.
|
||||
ExploreStudyset published(
|
||||
String title, {
|
||||
String publicId = 'japanese',
|
||||
String handle = 'alice',
|
||||
String displayName = 'Alice',
|
||||
String? frontLanguage,
|
||||
int cards = 812,
|
||||
int paths = 0,
|
||||
int? learners,
|
||||
String? basedOn,
|
||||
String? yours,
|
||||
}) => ExploreStudyset(
|
||||
publicId: publicId,
|
||||
location: 'halkyon.eu/@$handle/$publicId',
|
||||
handle: handle,
|
||||
displayName: displayName,
|
||||
version: 3,
|
||||
releasedAt: DateTime.utc(2026, 10, 9),
|
||||
notes: 'N4 kanji',
|
||||
title: title,
|
||||
description: RichContent.plain('Kana, kanji and grammar from N5 on.'),
|
||||
frontLanguage: frontLanguage,
|
||||
license: 'CC-BY-SA-4.0',
|
||||
licenseHolder: 'Alice',
|
||||
cards: cards,
|
||||
paths: paths,
|
||||
learners: learners,
|
||||
basedOn: basedOn,
|
||||
yours: yours,
|
||||
);
|
||||
|
||||
/// The JSON a synced [payload] holds.
|
||||
Map<String, Object?> json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
/// Release 3 of the published studyset [publicId]: a studyset titled
|
||||
/// [title] with one card.
|
||||
PublishedRelease release(String publicId, String title) => PublishedRelease(
|
||||
publicId: publicId,
|
||||
version: 3,
|
||||
items: {
|
||||
'studyset.json': {
|
||||
'id': 'root',
|
||||
'payload': json(encodeStudysetPayload(StudysetContent.titled(title))),
|
||||
},
|
||||
'cards/cat.json': {
|
||||
'id': 'cat',
|
||||
'position': 'a0',
|
||||
'tags': const <String>[],
|
||||
'payload': json(
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.plain('cat'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysets;
|
||||
|
||||
setUp(() => studysets = FakeStudysetGateway());
|
||||
|
||||
Future<void> showExplore(
|
||||
WidgetTester tester,
|
||||
FakeExploreGateway explore, {
|
||||
bool identified = true,
|
||||
FakeProfileGateway? profiles,
|
||||
FakeCardGateway? cards,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(900, 1400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
authGateway: FakeAuthGateway(
|
||||
status: SignedIn(
|
||||
authUserId: 'github-person',
|
||||
isIdentified: identified,
|
||||
),
|
||||
),
|
||||
studysetGateway: studysets,
|
||||
exploreGateway: explore,
|
||||
profileGateway: profiles,
|
||||
cardGateway: cards,
|
||||
),
|
||||
location: RoutePaths.explore,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('lists what is published, newest first, with its author, '
|
||||
'latest release, what it holds and its licence', (tester) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(published: [published('Japanese')]),
|
||||
);
|
||||
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
// Its author, release and licence quieter, below what it holds.
|
||||
expect(find.text('Kana, kanji and grammar from N5 on.'), findsOneWidget);
|
||||
expect(find.text('812 flashcards'), findsOneWidget);
|
||||
expect(find.text('@alice · v3 · CC BY-SA 4.0'), findsOneWidget);
|
||||
expect(find.text('Add'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('says how many learn it here, from 5 on, and what a copy is '
|
||||
'based on', (tester) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [
|
||||
published(
|
||||
'Japanese',
|
||||
learners: 10,
|
||||
basedOn: 'halkyon.eu/@tae/japanese',
|
||||
),
|
||||
published('Latin', publicId: 'latin'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('10+ learn this here'), findsOneWidget);
|
||||
expect(find.text('based on halkyon.eu/@tae/japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an anonymous person explores too', (tester) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(published: [published('Japanese')]),
|
||||
identified: false,
|
||||
);
|
||||
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('searches once typing pauses', (tester) async {
|
||||
final explore = FakeExploreGateway(
|
||||
published: [
|
||||
published('Japanese'),
|
||||
published('Latin', publicId: 'latin', handle: 'bob'),
|
||||
],
|
||||
);
|
||||
await showExplore(tester, explore);
|
||||
|
||||
await tester.enterText(find.byType(SearchBar), 'lat');
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(explore.asked, hasLength(1));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(explore.asked.last.filter.text, 'lat');
|
||||
expect(find.text('Latin'), findsOneWidget);
|
||||
expect(find.text('Japanese'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('filters by language and by kind of content', (tester) async {
|
||||
final explore = FakeExploreGateway(
|
||||
published: [
|
||||
published('Japanese', frontLanguage: 'ja'),
|
||||
published('Latin', publicId: 'latin', paths: 2),
|
||||
],
|
||||
);
|
||||
await showExplore(tester, explore);
|
||||
|
||||
await tester.tap(find.text('Languages'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Any').first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Japanese').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pumpAndSettle();
|
||||
expect(explore.asked.last.filter.frontLanguage, 'ja');
|
||||
expect(find.text('Japanese → Any'), findsOneWidget);
|
||||
expect(find.text('Latin'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Content'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Learning paths'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pumpAndSettle();
|
||||
expect(explore.asked.last.filter.kinds, {ContentKind.paths});
|
||||
expect(find.text('Nothing published here matches.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Clear filters'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Latin'), findsOneWidget);
|
||||
expect(explore.asked.last.filter, const ExploreFilter());
|
||||
});
|
||||
|
||||
testWidgets('shows the next page only when asked', (tester) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [
|
||||
published('One', publicId: 'one'),
|
||||
published('Two', publicId: 'two'),
|
||||
published('Three', publicId: 'three'),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(find.text('Three'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Show more'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Three'), findsOneWidget);
|
||||
expect(find.text('Show more'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('says when nothing is published yet', (tester) async {
|
||||
await showExplore(tester, FakeExploreGateway());
|
||||
|
||||
expect(
|
||||
find.text('Studysets that others share will appear here.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a licence holder of the instance is named by their handle, '
|
||||
"without the @, opening their profile; one from elsewhere isn't", (
|
||||
tester,
|
||||
) async {
|
||||
final japanese = published('Japanese');
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [
|
||||
ExploreStudyset(
|
||||
publicId: japanese.publicId,
|
||||
location: japanese.location,
|
||||
handle: 'alice',
|
||||
version: 3,
|
||||
releasedAt: japanese.releasedAt,
|
||||
title: 'Japanese',
|
||||
description: japanese.description,
|
||||
license: 'CC-BY-SA-4.0',
|
||||
licenseHolder: '@alice',
|
||||
),
|
||||
],
|
||||
),
|
||||
profiles: FakeProfileGateway(
|
||||
others: {
|
||||
'alice': const PublicProfile(
|
||||
handle: 'alice',
|
||||
address: 'alice@halkyon.eu',
|
||||
displayName: 'Alice',
|
||||
bio: 'Learning Japanese.',
|
||||
links: ['https://alice.example', 'https://blog.example'],
|
||||
verifiedLinks: ['https://alice.example'],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('@alice', findRichText: true), findsWidgets);
|
||||
expect(find.text('alice'), findsOneWidget);
|
||||
await tester.tap(find.text('alice'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Learning Japanese.'), findsOneWidget);
|
||||
expect(find.text('@alice@halkyon.eu'), findsOneWidget);
|
||||
// The link whose page links back to her profile is marked; the other
|
||||
// isn't.
|
||||
expect(find.byTooltip('Links back to this profile'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'alice.example'),
|
||||
matching: find.byTooltip('Links back to this profile'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a summary shows all of it, and adds it to the library as '
|
||||
'a copy, opened from there', (tester) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [published('Japanese')],
|
||||
releases: [release('japanese', 'Japanese')],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Latest release: v3 · Oct 9, 2026'), findsOneWidget);
|
||||
expect(find.text('N4 kanji'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Kana, kanji and grammar from N5 on.', findRichText: true),
|
||||
findsWidgets,
|
||||
);
|
||||
await tester.tap(find.text('Add to library').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final copies = await StudysetRepository(studysets).all();
|
||||
expect(copies.single.content.title, 'Japanese');
|
||||
expect(copies.single.content.publishedAt, 'halkyon.eu/@alice/japanese');
|
||||
expect(find.text('Added to your library.'), findsOneWidget);
|
||||
expect(find.text('In library'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Japanese'), findsWidgets);
|
||||
expect(find.byType(StudysetScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a studyset the person has a copy of is in their library '
|
||||
'already', (tester) async {
|
||||
studysets.receiveRemoteChange(
|
||||
'mine',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
published: const PublishedRef(
|
||||
publication: 'japanese',
|
||||
item: 'root',
|
||||
version: 2,
|
||||
),
|
||||
publishedAt: 'halkyon.eu/@alice/japanese',
|
||||
),
|
||||
),
|
||||
);
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(published: [published('Japanese')]),
|
||||
);
|
||||
|
||||
expect(find.text('In library'), findsOneWidget);
|
||||
expect(find.text('Add'), findsNothing);
|
||||
});
|
||||
|
||||
group("the person's own studyset", () {
|
||||
setUp(
|
||||
() => studysets.receiveRemoteChange(
|
||||
'mine',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('shows as theirs and opens, adding no copy; the summary adds '
|
||||
'a copy anyway when asked', (tester) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [published('Japanese', yours: 'mine')],
|
||||
releases: [release('japanese', 'Japanese')],
|
||||
),
|
||||
);
|
||||
expect(find.text('Add'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Yours'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(StudysetScreen), findsOneWidget);
|
||||
expect(await StudysetRepository(studysets).all(), hasLength(1));
|
||||
|
||||
await openTab(tester, 'Explore');
|
||||
await tester.tap(find.text('Japanese').first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add a copy anyway'));
|
||||
await tester.pumpAndSettle();
|
||||
final all = await StudysetRepository(studysets).all();
|
||||
expect(all, hasLength(2));
|
||||
expect(
|
||||
all.where((studyset) => studyset.content.publishedAt != null),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('deleted from the library, it can be added again', (
|
||||
tester,
|
||||
) async {
|
||||
studysets.receiveRemoteChange(
|
||||
'mine',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
deletedAt: DateTime.utc(2026, 10, 15),
|
||||
);
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [published('Japanese', yours: 'mine')],
|
||||
releases: [release('japanese', 'Japanese')],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Yours'), findsNothing);
|
||||
expect(find.text('Add'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a release in a schema this app does not know is not added, '
|
||||
'and says why', (tester) async {
|
||||
final newer = release('japanese', 'Japanese');
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [published('Japanese')],
|
||||
releases: [
|
||||
PublishedRelease(
|
||||
publicId: 'japanese',
|
||||
version: 3,
|
||||
items: {
|
||||
...newer.items,
|
||||
'cards/future.json': {
|
||||
'id': 'future',
|
||||
'payload': {'schemaVersion': cardSchemaVersion + 1},
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('This studyset needs a newer version of the app.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(await StudysetRepository(studysets).all(), isEmpty);
|
||||
});
|
||||
|
||||
group('says what kept a studyset from being added', () {
|
||||
Future<void> add(
|
||||
WidgetTester tester, {
|
||||
Exception? failRelease,
|
||||
FakeCardGateway? cards,
|
||||
}) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [published('Japanese')],
|
||||
releases: [release('japanese', 'Japanese')],
|
||||
)..failRelease = failRelease,
|
||||
cards: cards,
|
||||
);
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('no connection, or no answer in time', (tester) async {
|
||||
await add(tester, failRelease: const InstanceUnreachable());
|
||||
expect(
|
||||
find.text("Couldn't reach your instance. Try again."),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("the instance's own error", (tester) async {
|
||||
await add(tester, failRelease: const InstanceFailed());
|
||||
expect(
|
||||
find.text(
|
||||
"Your instance couldn't give out this studyset. Try again later.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('this device, writing it', (tester) async {
|
||||
await add(
|
||||
tester,
|
||||
cards: FakeCardGateway()..failWrites = Exception('full'),
|
||||
);
|
||||
expect(
|
||||
find.text("Couldn't add it to your library on this device."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('reach'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('without the instance, it says so and tries again', (
|
||||
tester,
|
||||
) async {
|
||||
final explore = FakeExploreGateway(published: [published('Japanese')])
|
||||
..fail = Exception('offline');
|
||||
await showExplore(tester, explore);
|
||||
|
||||
expect(find.text("Explore can't be reached right now."), findsOneWidget);
|
||||
explore.fail = null;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,490 @@
|
|||
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/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.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/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.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_payload.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_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.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/exam_plan.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 '../../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/fake_tag_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
|
||||
/// The JSON a synced [payload] holds.
|
||||
Map<String, Object?> json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
/// An item's file as a release holds it.
|
||||
Map<String, Object?> item(
|
||||
String id,
|
||||
ByteData payload, {
|
||||
String position = 'a0',
|
||||
List<String>? tags,
|
||||
String? kind,
|
||||
}) => {
|
||||
'id': id,
|
||||
'position': position,
|
||||
'tags': ?tags,
|
||||
'kind': ?kind,
|
||||
'payload': json(payload),
|
||||
};
|
||||
|
||||
const imageAsset = '0192aaaa-0000-7000-8000-000000000001';
|
||||
const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
|
||||
final image = Uint8List.fromList(utf8.encode('an image'));
|
||||
final book = Uint8List.fromList(utf8.encode('%PDF-1.7 a book'));
|
||||
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late NoteRepository notes;
|
||||
late LearningPathRepository paths;
|
||||
late PdfSourceRepository pdfs;
|
||||
late TagRepository tags;
|
||||
late AssetRepository assets;
|
||||
late ReleaseCopier copier;
|
||||
late FakeTagGateway tagGateway;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
notes = NoteRepository(FakeNoteGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
tagGateway = FakeTagGateway();
|
||||
tags = TagRepository(
|
||||
tagGateway,
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
copier = ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets);
|
||||
});
|
||||
|
||||
CardContent card(String front, String back) => CardContent(
|
||||
front: RichContent.plain(front),
|
||||
back: RichContent.plain(back),
|
||||
);
|
||||
|
||||
/// Release 3 of a Japanese studyset holding Kanji, as the instance gives
|
||||
/// it out: cards in both (the second out of order, tagged; one showing an
|
||||
/// image), a note, a path of two steps, and a PDF in Kanji.
|
||||
PublishedRelease japanese() => PublishedRelease(
|
||||
publicId: 'publication-1',
|
||||
version: 3,
|
||||
items: {
|
||||
'studyset.json': item(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.plain('From N5 on.'),
|
||||
license: 'CC-BY-SA-4.0',
|
||||
licenseHolder: 'Alice',
|
||||
),
|
||||
),
|
||||
),
|
||||
'kanji/studyset.json': item(
|
||||
'kanji',
|
||||
encodeStudysetPayload(StudysetContent.titled('Kanji')),
|
||||
position: 'a1',
|
||||
),
|
||||
'cards/second.json': item(
|
||||
'second',
|
||||
encodeCardPayload(card('犬', 'dog')),
|
||||
position: 'a1',
|
||||
tags: ['JLPT-N5', 'Noun'],
|
||||
),
|
||||
'cards/first.json': item(
|
||||
'first',
|
||||
encodeCardPayload(card('猫', 'cat')),
|
||||
tags: ['JLPT-N5'],
|
||||
),
|
||||
'kanji/cards/pictured.json': item(
|
||||
'pictured',
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl(imageAsset)},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
back: RichContent.plain('絵'),
|
||||
),
|
||||
),
|
||||
),
|
||||
'notes/particles.json': item(
|
||||
'particles',
|
||||
encodeNotePayload(
|
||||
NoteContent(title: 'Particles', body: RichContent.plain('は')),
|
||||
),
|
||||
tags: ['Grammar'],
|
||||
),
|
||||
'paths/hiragana/path.json': item(
|
||||
'hiragana',
|
||||
encodeLearningPathPayload(LearningPathContent.titled('Hiragana')),
|
||||
),
|
||||
'paths/hiragana/steps/two.json': item(
|
||||
'two',
|
||||
encodeStepPayload(
|
||||
StepContent.empty(StepKind.info).withField(StepField.title, 'い'),
|
||||
),
|
||||
position: 'a1',
|
||||
kind: 'info',
|
||||
),
|
||||
'paths/hiragana/steps/one.json': item(
|
||||
'one',
|
||||
encodeStepPayload(
|
||||
StepContent.empty(StepKind.info).withField(StepField.title, 'あ'),
|
||||
),
|
||||
kind: 'info',
|
||||
),
|
||||
'kanji/pdfs/book.json': item(
|
||||
'book',
|
||||
encodePdfSourcePayload(
|
||||
const PdfSourceContent(
|
||||
title: 'A book',
|
||||
fileName: 'book.pdf',
|
||||
assetId: bookAsset,
|
||||
pageCount: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
'files.json': {
|
||||
imageAsset: {
|
||||
'sha256': 'image-hash',
|
||||
'mimeType': 'image/png',
|
||||
'size': image.length,
|
||||
'payload': {'schemaVersion': 1, 'fileName': 'cat.png'},
|
||||
},
|
||||
bookAsset: {
|
||||
'sha256': 'book-hash',
|
||||
'mimeType': 'application/pdf',
|
||||
'size': book.length,
|
||||
'payload': {'schemaVersion': 1, 'fileName': 'book.pdf'},
|
||||
},
|
||||
},
|
||||
},
|
||||
files: const ['image-hash', 'book-hash'],
|
||||
);
|
||||
|
||||
Future<Uint8List> fileOf(String sha256) async =>
|
||||
sha256 == 'image-hash' ? image : book;
|
||||
|
||||
test("a copy is the person's own studysets, each item knowing where it "
|
||||
'came from', () async {
|
||||
final progress = <double>[];
|
||||
|
||||
final id = await copier.copy(
|
||||
japanese(),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
onProgress: progress.add,
|
||||
);
|
||||
|
||||
final all = await studysets.all();
|
||||
final root = all.singleWhere((studyset) => studyset.id == id);
|
||||
expect(root.parentId, isNull);
|
||||
expect(root.content.title, 'Japanese');
|
||||
expect(root.content.license, 'CC-BY-SA-4.0');
|
||||
expect(
|
||||
root.content.published,
|
||||
const PublishedRef(
|
||||
publication: 'publication-1',
|
||||
item: 'japanese',
|
||||
version: 3,
|
||||
),
|
||||
);
|
||||
expect(root.content.publishedAt, 'halkyon.eu/@alice/japanese');
|
||||
final kanji = all.singleWhere((studyset) => studyset.parentId == id);
|
||||
expect(kanji.content.published?.item, 'kanji');
|
||||
expect(kanji.content.publishedAt, isNull);
|
||||
expect(progress.last, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
"its exam plans come along, their topics the copy's own studysets",
|
||||
() async {
|
||||
final release = japanese();
|
||||
final withPlans = PublishedRelease(
|
||||
publicId: release.publicId,
|
||||
version: release.version,
|
||||
items: {
|
||||
...release.items,
|
||||
'studyset.json': item(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent.titled('Japanese').withExamPlans(const [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji test',
|
||||
topics: ['kanji'],
|
||||
tags: ['JLPT-N5'],
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
},
|
||||
files: release.files,
|
||||
);
|
||||
|
||||
final id = await copier.copy(
|
||||
withPlans,
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
onProgress: (_) {},
|
||||
);
|
||||
|
||||
final all = await studysets.all();
|
||||
final root = all.singleWhere((studyset) => studyset.id == id);
|
||||
final kanji = all.singleWhere((studyset) => studyset.parentId == id);
|
||||
expect(root.content.examPlans, [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji test',
|
||||
topics: [kanji.id],
|
||||
tags: const ['JLPT-N5'],
|
||||
),
|
||||
]);
|
||||
expect(root.content.exam, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test("cards come in their order with their tags by name, the person's "
|
||||
'own tag of a name taken', () async {
|
||||
final noun = await tags.create('noun');
|
||||
|
||||
final id = await copier.copy(
|
||||
japanese(),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final copied = await cards.cardsOf(id);
|
||||
expect(
|
||||
[for (final card in copied) card.content.front.plainText],
|
||||
[
|
||||
'猫',
|
||||
'犬',
|
||||
],
|
||||
);
|
||||
expect(
|
||||
copied.first.content.published,
|
||||
const PublishedRef(
|
||||
publication: 'publication-1',
|
||||
item: 'first',
|
||||
version: 3,
|
||||
),
|
||||
);
|
||||
final tagging = await tags.read();
|
||||
expect(
|
||||
[for (final tag in tagging.of(copied.last.id)) tag.name]..sort(),
|
||||
['JLPT-N5', 'noun'],
|
||||
);
|
||||
expect(tagging.of(copied.last.id).map((tag) => tag.id), contains(noun));
|
||||
});
|
||||
|
||||
test("a release's tags are written with one read of the tags for each "
|
||||
'studyset, not one for each tag: a language has dozens, and each read '
|
||||
"takes in every card's tags", () async {
|
||||
final release = PublishedRelease(
|
||||
publicId: 'publication-1',
|
||||
version: 1,
|
||||
items: {
|
||||
'studyset.json': item(
|
||||
'root',
|
||||
encodeStudysetPayload(StudysetContent.titled('Words')),
|
||||
),
|
||||
for (var index = 0; index < 40; index++)
|
||||
'cards/card-$index.json': item(
|
||||
'card-$index',
|
||||
encodeCardPayload(card('$index', 'number $index')),
|
||||
position: 'a${index.toString().padLeft(2, '0')}',
|
||||
tags: ['Tag $index', 'Numbers'],
|
||||
),
|
||||
},
|
||||
);
|
||||
final before = tagGateway.cardTagReads;
|
||||
|
||||
final id = await copier.copy(
|
||||
release,
|
||||
location: 'halkyon.eu/@alice/words',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
expect(tagGateway.cardTagReads - before, lessThanOrEqualTo(4));
|
||||
final tagging = await tags.read();
|
||||
final copied = await cards.cardsOf(id);
|
||||
expect(
|
||||
{
|
||||
for (final card in copied)
|
||||
for (final tag in tagging.of(card.id)) tag.name,
|
||||
},
|
||||
hasLength(41),
|
||||
);
|
||||
});
|
||||
|
||||
test("the files its content names become the person's own", () async {
|
||||
final id = await copier.copy(
|
||||
japanese(),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final kanji = (await studysets.all()).singleWhere(
|
||||
(studyset) => studyset.parentId == id,
|
||||
);
|
||||
final pictured = (await cards.cardsOf(kanji.id)).single;
|
||||
final assetId = pictured.content.front.assetIds.single;
|
||||
expect(assetId, isNot(imageAsset));
|
||||
expect((await assets.fileOf(assetId))!.bytes, image);
|
||||
final pdf = (await pdfs.sourcesOf(kanji.id)).single;
|
||||
expect(pdf.content.title, 'A book');
|
||||
expect(pdf.content.pageCount, 12);
|
||||
expect(pdf.content.published?.item, 'book');
|
||||
expect((await assets.fileOf(pdf.content.assetId))!.bytes, book);
|
||||
});
|
||||
|
||||
test("a card or note tied to a page of the release's PDF is tied to the "
|
||||
'PDF made of it', () async {
|
||||
final release = japanese();
|
||||
final id = await copier.copy(
|
||||
PublishedRelease(
|
||||
publicId: release.publicId,
|
||||
version: release.version,
|
||||
items: {
|
||||
...release.items,
|
||||
'kanji/cards/page.json': item(
|
||||
'page',
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent.plain('本'),
|
||||
back: RichContent.plain('book'),
|
||||
source: (pdfId: 'book', page: 2),
|
||||
),
|
||||
),
|
||||
position: 'a5',
|
||||
),
|
||||
'kanji/notes/reading.json': item(
|
||||
'reading',
|
||||
encodeNotePayload(
|
||||
NoteContent(
|
||||
body: RichContent.plain('Chapter 1'),
|
||||
source: (pdfId: 'book', page: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
files: release.files,
|
||||
),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final kanji = (await studysets.all()).singleWhere(
|
||||
(studyset) => studyset.parentId == id,
|
||||
);
|
||||
final pdf = (await pdfs.sourcesOf(kanji.id)).single;
|
||||
final page = (await cards.cardsOf(
|
||||
kanji.id,
|
||||
)).singleWhere((card) => card.content.front.plainText == '本');
|
||||
expect(page.content.source, (pdfId: pdf.id, page: 2));
|
||||
expect(
|
||||
(await notes.notesOf(kanji.id)).single.content.source,
|
||||
(pdfId: pdf.id, page: 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('notes and learning paths, steps in their order', () async {
|
||||
final id = await copier.copy(
|
||||
japanese(),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final note = (await notes.notesOf(id)).single;
|
||||
expect(note.content.title, 'Particles');
|
||||
expect(note.content.published?.item, 'particles');
|
||||
expect(
|
||||
[
|
||||
for (final tag in (await tags.read()).ofNote(note.id)) tag.name,
|
||||
],
|
||||
['Grammar'],
|
||||
);
|
||||
final path = (await paths.pathsOf(id)).single;
|
||||
expect(path.content.published?.item, 'hiragana');
|
||||
expect(
|
||||
[
|
||||
for (final step in await paths.stepsOf(path.id)) step.content.title,
|
||||
],
|
||||
['あ', 'い'],
|
||||
);
|
||||
});
|
||||
|
||||
test("a release this app can't read all of is not copied at all", () async {
|
||||
final newer = japanese();
|
||||
final items = {
|
||||
...newer.items,
|
||||
'cards/future.json': {
|
||||
'id': 'future',
|
||||
'position': 'a2',
|
||||
'payload': {'schemaVersion': cardSchemaVersion + 1, 'type': 'hologram'},
|
||||
},
|
||||
};
|
||||
|
||||
await expectLater(
|
||||
copier.copy(
|
||||
PublishedRelease(
|
||||
publicId: newer.publicId,
|
||||
version: newer.version,
|
||||
items: items,
|
||||
files: newer.files,
|
||||
),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
),
|
||||
throwsA(isA<ReleaseUnreadable>()),
|
||||
);
|
||||
expect(await studysets.all(), isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
const publicId = '01a0fc5b-0168-775a-aca8-9a36cdb471a4';
|
||||
|
||||
/// Release 2 of [publicId]: [count] JSON files, `cards/0000.json` on.
|
||||
List<protocol.ReleaseItem> items(int count) => [
|
||||
for (var index = 0; index < count; index++)
|
||||
protocol.ReleaseItem(
|
||||
path: 'cards/${index.toString().padLeft(4, '0')}.json',
|
||||
json: jsonEncode({'id': 'card-$index'}),
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
/// What the instance was asked, by method and arguments, in order.
|
||||
late List<(String, Map<String, Object?>)> asked;
|
||||
|
||||
ServerpodExploreGateway gateway(
|
||||
Future<http.Response> Function(String method, Map<String, Object?> args)
|
||||
answer,
|
||||
) {
|
||||
asked = [];
|
||||
final client = protocol.Client(
|
||||
'https://learn.example.org/',
|
||||
httpClientOverride: MockClient((request) {
|
||||
final method = request.url.pathSegments.last;
|
||||
final args = jsonDecode(request.body) as Map<String, Object?>;
|
||||
asked.add((method, args));
|
||||
return answer(method, args);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
return ServerpodExploreGateway(client);
|
||||
}
|
||||
|
||||
http.Response ok(Object answer) =>
|
||||
http.Response(protocol.SerializationManager.encode(answer), 200);
|
||||
|
||||
/// The parts of a release of [total] files, [size] to a part.
|
||||
http.Response partOf(Map<String, Object?> args, int total, int size) {
|
||||
final offset = args['offset']! as int;
|
||||
return ok(
|
||||
protocol.ReleasePart(
|
||||
publicId: protocol.UuidValue.withValidation(publicId),
|
||||
version: 2,
|
||||
total: total,
|
||||
items: items(total).skip(offset).take(size).toList(),
|
||||
files: ['a' * 64],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test('a release comes in parts, each from where the last ended and of '
|
||||
'the version the first named, saying how far it has come', () async {
|
||||
final fetched = <(int, int)>[];
|
||||
final release = await gateway(
|
||||
(method, args) async => partOf(args, 1200, 500),
|
||||
).release(publicId, onFetched: (done, total) => fetched.add((done, total)));
|
||||
|
||||
expect(release.version, 2);
|
||||
expect(release.items, hasLength(1200));
|
||||
expect(release.items['cards/1199.json'], {'id': 'card-1199'});
|
||||
expect(release.files, ['a' * 64]);
|
||||
expect([for (final (_, args) in asked) args['offset']], [0, 500, 1000]);
|
||||
expect([for (final (_, args) in asked) args['version']], [null, 2, 2]);
|
||||
expect(fetched, [(500, 1200), (1000, 1200), (1200, 1200)]);
|
||||
});
|
||||
|
||||
test('a part whose connection drops is asked for again, alone', () async {
|
||||
var drops = 1;
|
||||
final release = await gateway((method, args) async {
|
||||
if (args['offset'] == 500 && drops-- > 0) {
|
||||
throw const SocketException('Connection reset by peer');
|
||||
}
|
||||
return partOf(args, 900, 500);
|
||||
}).release(publicId);
|
||||
|
||||
expect(release.items, hasLength(900));
|
||||
expect([for (final (_, args) in asked) args['offset']], [0, 500, 500]);
|
||||
});
|
||||
|
||||
test('an instance from before parts gives the release whole', () async {
|
||||
final release = await gateway(
|
||||
(method, args) async => switch (method) {
|
||||
'releasePart' => http.Response('Method not found in endpoint', 400),
|
||||
_ => ok(
|
||||
protocol.ReleaseContent(
|
||||
publicId: protocol.UuidValue.withValidation(publicId),
|
||||
version: 1,
|
||||
items: items(3),
|
||||
files: const [],
|
||||
),
|
||||
),
|
||||
},
|
||||
).release(publicId);
|
||||
|
||||
expect(release.version, 1);
|
||||
expect(release.items, hasLength(3));
|
||||
expect(
|
||||
[for (final (method, _) in asked) method],
|
||||
[
|
||||
'releasePart',
|
||||
'release',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('says whether the instance could not be reached or answered with an '
|
||||
'error', () async {
|
||||
await expectLater(
|
||||
gateway(
|
||||
(method, args) async => throw const SocketException('No route'),
|
||||
).release(publicId),
|
||||
throwsA(isA<InstanceUnreachable>()),
|
||||
);
|
||||
await expectLater(
|
||||
gateway(
|
||||
(method, args) async => http.Response('Internal server error', 500),
|
||||
).release(publicId),
|
||||
throwsA(isA<InstanceFailed>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue