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

View file

@ -0,0 +1,197 @@
import 'package:flag/flag.dart';
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/app/learn_frame.dart';
import 'package:halkyon_learn_flutter/src/app/learn_sessions.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
import 'package:material_ui/material_ui.dart';
import '../support/memory_device_settings_store.dart';
import '../support/pump_app.dart';
import '../support/test_dependencies.dart';
void main() {
final builtIn = Instance.parse('learn.halkyon.eu')!;
final other = Instance.parse('learn.example.org')!;
late MemoryInstanceStore store;
late List<Instance> opened;
late List<Instance> forgotten;
setUp(() {
store = MemoryInstanceStore();
opened = [];
forgotten = [];
});
/// Starts the app on a device that [usedBefore] the choice existed, with
/// [answering] instances answering a check, saying where they keep data
/// as [locations] has it.
Future<InstanceChoice> start(
WidgetTester tester, {
bool usedBefore = false,
Set<Instance> answering = const {},
Map<Instance, InstanceLocations> locations = const {},
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final choice = InstanceChoice(
store,
builtIn: builtIn,
forget: (instance) async => forgotten.add(instance),
);
await choice.load(usedBefore: usedBefore);
await tester.pumpWidget(
InstanceGate(
frame: learnFrame,
choice: choice,
checker: FakeInstanceChecker(answering, locations),
countryNames: MemoryCountryNames(const {
'CH': {'en': 'Switzerland'},
}),
readLook: () async => (await MemoryDeviceSettingsStore().read()).look,
open: (instance) async {
opened.add(instance);
return testDependencies();
},
app: const LearnSessions(),
),
);
await tester.pumpAndSettle();
return choice;
}
/// Opens Settings › Switch account, further down Settings.
Future<void> openSwitcher(WidgetTester tester) async {
await openTab(tester, 'Profile');
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Switch account'),
200,
// Settings' list; its citation preview is a scroll view of its own.
scrollable: find
.descendant(
of: find.byType(SettingsScreen),
matching: find.byType(Scrollable),
)
.first,
);
// Built, but perhaps still below the window's edge.
await tester.ensureVisible(find.text('Switch account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Switch account'));
await tester.pumpAndSettle();
}
testWidgets('a new install asks for the instance first, and opens the '
"build's in one tap", (tester) async {
await start(tester);
// The wordmark says it to screen readers.
expect(find.bySemanticsLabel('Welcome to Halkyon Learn'), findsOneWidget);
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
expect(find.byType(TextField), findsNothing, reason: 'no address asked');
await tester.tap(find.text('learn.halkyon.eu'));
await tester.pumpAndSettle();
expect(opened, [builtIn]);
expect(tab('Home'), findsOneWidget);
});
testWidgets("the build's instance shows where it keeps people's data, "
'once it says', (tester) async {
await start(
tester,
answering: {builtIn},
locations: {
builtIn: const InstanceLocations(data: 'EU', backups: 'CH'),
},
);
expect(find.text('Data: European Union\nBackups: Switzerland'), findsOne);
expect(find.byType(Flag), findsOne);
});
testWidgets('an instance saying nothing, or not answering, comes with the '
'app, without a flag', (tester) async {
await start(tester);
expect(find.text('Comes with the app'), findsOne);
expect(find.byType(Flag), findsNothing);
});
testWidgets('another instance is checked before the app moves there', (
tester,
) async {
await start(tester, answering: {other});
await tester.tap(find.text('Another instance'));
await tester.pumpAndSettle();
expect(
FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<TextField>(),
isNotNull,
reason: 'the address is typed at once',
);
await tester.enterText(find.byType(TextField), 'nowhere.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(
find.text(
'No instance of Halkyon Learn answers at nowhere.example.org.',
),
findsOneWidget,
);
expect(opened, isEmpty);
await tester.enterText(find.byType(TextField), 'learn.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(opened, [other]);
});
testWidgets('an install from before goes on without asking', (tester) async {
await start(tester, usedBefore: true);
expect(opened, [builtIn]);
expect(tab('Home'), findsOneWidget);
});
testWidgets('Settings switches between the accounts on the device, and '
'removes one not in use', (tester) async {
store = MemoryInstanceStore((current: builtIn, known: [builtIn, other]));
await start(
tester,
answering: {builtIn, other},
locations: {
other: const InstanceLocations(
data: 'CH',
backups: InstanceLocations.noBackups,
),
},
);
await openSwitcher(tester);
expect(find.text('learn.halkyon.eu'), findsWidgets);
// Each account with where its instance says it keeps people's data.
expect(find.text('Data: Switzerland\nNo backups'), findsOne);
expect(find.byType(Flag), findsOne);
await tester.tap(find.text('learn.example.org'));
await tester.pumpAndSettle();
expect(opened, [builtIn, other]);
expect(tab('Home'), findsOneWidget, reason: 'a new app, on Home');
await openSwitcher(tester);
await tester.tap(find.byTooltip('Remove account'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
await tester.pumpAndSettle();
expect(forgotten, [builtIn]);
expect(store.kept?.known, [other]);
});
}

View file

@ -0,0 +1,238 @@
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/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.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/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.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/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.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:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/fake_card_gateway.dart';
import '../support/fake_discussion_gateway.dart';
import '../support/fake_learning_path_gateway.dart';
import '../support/fake_note_gateway.dart';
import '../support/fake_settings_gateway.dart';
import '../support/fake_studyset_gateway.dart';
import '../support/fake_tag_gateway.dart';
import '../support/pump_app.dart';
import '../support/test_dependencies.dart';
/// Names longer than the fields allow ([TextLimits]), as an older app, an
/// import or another person's release can bring them: every screen that
/// shows them cuts them short rather than overflowing, which fails a test.
void main() {
/// [length] characters of a name made of words, as long names are.
String long(String word, int length) =>
List.filled(length, word).join(' ').substring(0, length);
final studysetTitle = long('Japanese', TextLimits.title * 2);
final kicker = long('日本語', TextLimits.secondary * 2);
final topicTitle = long('Grammar', TextLimits.title * 2);
final tagName = long('Particles', TextLimits.name * 3);
final profileName = long('Deep focus', TextLimits.name * 3);
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeNoteGateway noteGateway;
late FakeLearningPathGateway pathGateway;
late FakeTagGateway tagGateway;
late FakeSettingsGateway settingsGateway;
late FakeDiscussionGateway discussions;
setUp(() async {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: studysetTitle,
secondaryTitle: kicker,
description: RichContent.plain(long('About', 400)),
),
),
)
..receiveRemoteChange(
'grammar',
encodeStudysetPayload(StudysetContent.titled(topicTitle)),
parentId: 'japanese',
)
..receiveRemoteChange(
'inner',
encodeStudysetPayload(StudysetContent.titled(topicTitle)),
parentId: 'grammar',
);
cardGateway = FakeCardGateway();
noteGateway = FakeNoteGateway();
pathGateway = FakeLearningPathGateway();
tagGateway = FakeTagGateway();
settingsGateway = FakeSettingsGateway();
final someone = PublicPerson(
handle: long('someone', 30).replaceAll(' ', '_'),
displayName: long('Someone', TextLimits.name * 3),
);
discussions = FakeDiscussionGateway(studysetTitle: studysetTitle)
..addQuestion(
long('Meaning', TextLimits.title * 2),
by: someone,
answers: [
FakeDiscussionGateway().answerBy(long('Answer', 300), by: someone),
],
);
final cards = CardRepository(cardGateway);
final cardId = await cards.create(
'japanese',
CardContent(
front: RichContent.plain(long('front', 300)),
back: RichContent.plain(long('back', 300)),
),
);
final tags = TagRepository(tagGateway, cardChanges: cards.changes);
await tags.setTagsOf(cardId, {await tags.create(tagName)});
await NoteRepository(noteGateway).create(
'japanese',
NoteContent(
title: long('Note', TextLimits.title * 2),
body: RichContent.empty(),
),
);
await LearningPathRepository(pathGateway).createPath(
'japanese',
LearningPathContent.titled(
long('Path', TextLimits.title * 2),
).withSecondaryTitle(long('Book', TextLimits.secondary * 2)),
);
final profile = PomodoroProfile(id: 'long', name: profileName);
await SettingsRepository(settingsGateway).update(
(settings) => settings.withPomodoro(
settings.pomodoro.withProfile(profile).withSelected(profile.id),
),
);
await Future.wait([tags.dispose(), cards.dispose()]);
});
for (final (window, width) in [('a phone', 360.0), ('a desktop', 1200.0)]) {
Future<void> show(
WidgetTester tester,
String location, {
AuthGateway? authGateway,
SignInAddresses? signInAddresses,
}) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
authGateway: authGateway,
signInAddresses: signInAddresses,
studysetGateway: studysets,
cardGateway: cardGateway,
noteGateway: noteGateway,
pathGateway: pathGateway,
tagGateway: tagGateway,
settingsGateway: settingsGateway,
discussionGateway: discussions,
),
location: location,
);
await tester.pump(const Duration(seconds: 1));
}
testWidgets('on $window, Home with a long Pomodoro profile', (
tester,
) async {
await show(tester, RoutePaths.home);
expect(find.textContaining('Deep focus'), findsWidgets);
await tester.tap(find.byTooltip(_profilesTooltip));
await tester.pumpAndSettle();
});
testWidgets('on $window, the library and a long studyset', (
tester,
) async {
await show(tester, RoutePaths.library);
expect(find.textContaining('Japanese'), findsWidgets);
});
testWidgets("on $window, a long studyset's screen, its parts and topics", (
tester,
) async {
await show(tester, RoutePaths.studyset('grammar'));
for (final part in ['Study plan', 'Material']) {
await openPart(tester, part);
}
if (width < 840) {
await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle();
}
});
testWidgets('on $window, the card browser with a long tag', (
tester,
) async {
await show(tester, RoutePaths.cardBrowser('japanese'));
await tester.tap(find.byTooltip('Detail'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Select'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage tags'));
await tester.pumpAndSettle();
});
testWidgets("on $window, a long studyset's discussions and a question", (
tester,
) async {
await show(
tester,
RoutePaths.discussions('pub-1', title: topicTitle),
);
await tester.pumpAndSettle();
await tester.tap(find.textContaining('Meaning'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
});
testWidgets('on $window, studying a long studyset', (tester) async {
await show(tester, RoutePaths.study('japanese'));
});
testWidgets('on $window, the profile of someone signing in with a long '
'email address', (tester) async {
final address =
'${long('anna', TextLimits.emailAddress - 12)}@example.org';
await show(
tester,
RoutePaths.profile,
authGateway: FakeAuthGateway(
status: const SignedIn(authUserId: 'anna', isIdentified: true),
)..addressOwners[address] = 'anna',
signInAddresses: MemorySignInAddresses()..addresses['anna'] = address,
);
await tester.pumpAndSettle();
expect(find.textContaining('anna anna'), findsOneWidget);
});
}
}
/// The tooltip of the Pomodoro card's profiles button.
const _profilesTooltip = 'Pomodoro profiles';

View file

@ -0,0 +1,104 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/bloc/sound_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_sound_player.dart';
import '../../../support/memory_asset_file_cache.dart';
void main() {
late MemoryAssetFileCache files;
late AssetRepository assets;
late FakeSoundPlayer player;
late String sound;
setUp(() async {
files = MemoryAssetFileCache();
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
files,
);
player = FakeSoundPlayer();
sound = await assets.add(
Uint8List.fromList(utf8.encode('neko')),
fileName: 'neko.mp3',
mimeType: 'audio/mpeg',
);
});
tearDown(() async {
await assets.dispose();
await player.dispose();
});
Future<SoundState> next(SoundBloc bloc, SoundState expected) =>
bloc.state == expected
? Future.value(expected)
: bloc.stream.firstWhere((state) => state == expected);
test('plays the sound, and stops it again', () async {
final bloc = SoundBloc(assets, player, sound)
..add(const SoundEventToggled());
await next(bloc, SoundState.playing);
expect(player.played, [files.pathOf(sha256Of(utf8.encode('neko')))]);
bloc.add(const SoundEventToggled());
await next(bloc, SoundState.idle);
expect(player.current, isNull);
await bloc.close();
});
test(
'stops showing it plays when it ends, or another sound starts',
() async {
final bloc = SoundBloc(assets, player, sound)
..add(const SoundEventToggled());
await next(bloc, SoundState.playing);
player.finish();
await next(bloc, SoundState.idle);
bloc.add(const SoundEventToggled());
await next(bloc, SoundState.playing);
await player.play('/cache/other', mimeType: 'audio/mpeg');
await next(bloc, SoundState.idle);
await bloc.close();
},
);
test(
'is missing while its file is not on this device or the server',
() async {
final bloc = SoundBloc(assets, player, 'not-uploaded')
..add(const SoundEventToggled());
await next(bloc, SoundState.missing);
expect(player.played, isEmpty);
await bloc.close();
},
);
test("is unplayable when its file is here but won't play", () async {
player.failPlay = Exception('Format not supported');
final bloc = SoundBloc(assets, player, sound)
..add(const SoundEventToggled());
await next(bloc, SoundState.unplayable);
await bloc.close();
});
test('stops its sound when it goes', () async {
final bloc = SoundBloc(assets, player, sound)
..add(const SoundEventToggled());
await next(bloc, SoundState.playing);
await bloc.close();
expect(player.current, isNull);
});
}

View file

@ -0,0 +1,24 @@
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/content/payload.dart';
void main() {
test('round-trips the name a file came with', () {
expect(
decodeAssetPayload(encodeAssetPayload(fileName: 'cat.png')),
'cat.png',
);
});
test('refuses what is not a file payload, or in a newer schema', () {
expect(
() => decodeAssetPayload(encodePayload(1, {'name': 'cat.png'})),
throwsFormatException,
);
expect(
() => decodeAssetPayload(encodePayload(2, {'fileName': 'cat.png'})),
throwsFormatException,
);
expect(() => decodeAssetPayload(redactedPayload), throwsFormatException);
});
}

View file

@ -0,0 +1,306 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
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/asset/model/stored_file.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
void main() {
late FakeAssetGateway rows;
late FakeAssetServerGateway server;
late MemoryAssetFileCache cache;
late StreamController<void> triggers;
late AssetRepository repository;
final image = Uint8List.fromList(utf8.encode('an image'));
final imageHash = sha256Of(image);
setUp(() {
rows = FakeAssetGateway();
server = FakeAssetServerGateway();
cache = MemoryAssetFileCache();
triggers = StreamController<void>.broadcast();
repository = AssetRepository(
rows,
server,
cache,
uploadTriggers: triggers.stream,
);
});
tearDown(() async {
await repository.dispose();
await triggers.close();
});
Future<String> addImage() =>
repository.add(image, fileName: 'cat.png', mimeType: 'image/png');
group('Adding', () {
test('keeps the file on this device, stores its row and waits to upload '
'it', () async {
final id = await addImage();
expect(cache.files, {imageHash: image});
final row = rows.rows.single;
expect(row.id, id);
expect(row.sha256, imageHash);
expect(row.mimeType, 'image/png');
expect(row.byteSize, image.length);
expect(decodeAssetPayload(row.payload), 'cat.png');
expect(cache.pending, {imageHash});
expect(server.started, isEmpty);
});
test('the same file again is the row it has already', () async {
final id = await addImage();
expect(
await repository.add(
image,
fileName: 'other.png',
mimeType: 'image/png',
),
id,
);
expect(rows.rows, hasLength(1));
});
test('adding the same file twice at once stores one row', () async {
final ids = await Future.wait([addImage(), addImage()]);
expect(ids.toSet(), hasLength(1));
expect(rows.rows, hasLength(1));
});
test(
'refuses an empty file and one larger than the server takes',
() async {
await expectLater(
repository.add(
Uint8List(0),
fileName: 'empty',
mimeType: 'image/png',
),
throwsArgumentError,
);
await expectLater(
repository.add(
Uint8List(AssetRepository.maxBytes + 1),
fileName: 'huge.png',
mimeType: 'image/png',
),
throwsArgumentError,
);
expect(rows.rows, isEmpty);
expect(cache.files, isEmpty);
},
);
});
group('Uploading', () {
test(
'uploads what waits, and stops waiting once the server keeps it',
() async {
await addImage();
await repository.uploadPending();
expect(server.files, {imageHash: image});
expect(cache.pending, isEmpty);
},
);
test('uploads whenever triggered', () async {
await addImage();
triggers.add(null);
await pumpEventQueue();
expect(server.files, {imageHash: image});
expect(cache.pending, isEmpty);
});
test('sends nothing the server has, and stops waiting', () async {
server.files[imageHash] = image;
await addImage();
server.failUploads = true;
await repository.uploadPending();
expect(server.started, [imageHash]);
expect(cache.pending, isEmpty);
});
test('keeps waiting when an upload does not arrive', () async {
await addImage();
server.failUploads = true;
await repository.uploadPending();
expect(server.files, isEmpty);
expect(cache.pending, {imageHash});
});
test('keeps waiting when the server refuses what arrived', () async {
await addImage();
server.corruptUploads = Uint8List.fromList([1, 2, 3]);
await repository.uploadPending();
expect(server.files, isEmpty);
expect(cache.pending, {imageHash});
});
test(
'keeps waiting without a connection, and goes on once there is one',
() async {
await addImage();
server.failCalls = Exception('offline');
await repository.uploadPending();
expect(cache.pending, {imageHash});
server.failCalls = null;
await repository.uploadPending();
expect(server.files, {imageHash: image});
expect(cache.pending, isEmpty);
},
);
test('keeps waiting while the server has no room, and asks no '
'further that round', () async {
await addImage();
await repository.add(
Uint8List.fromList(utf8.encode('another image')),
fileName: 'dog.png',
mimeType: 'image/png',
);
server.limitBytes = 4;
await repository.uploadPending();
expect(server.files, isEmpty);
expect(cache.pending, hasLength(2));
expect(server.started, hasLength(1));
server.limitBytes = 1 << 20;
await repository.uploadPending();
expect(cache.pending, isEmpty);
});
test('tells how much room the files take, and may take', () async {
server.files[imageHash] = image;
server.limitBytes = 100;
expect(await repository.room(), (
usedBytes: image.length,
limitBytes: 100,
));
server.failCalls = Exception('offline');
expect(await repository.room(), isNull);
});
test('stops waiting for a file gone from this device', () async {
await addImage();
cache.files.clear();
await repository.uploadPending();
expect(server.started, isEmpty);
expect(cache.pending, isEmpty);
});
});
group('Reading', () {
/// A row of [image] as another device stores it, uploaded to the server.
void imageFromElsewhere() {
rows.receiveRemote((
id: 'remote-image',
sha256: imageHash,
mimeType: 'image/png',
byteSize: image.length,
payload: encodeAssetPayload(fileName: 'cat.png'),
));
server.rows['remote-image'] = imageHash;
server.files[imageHash] = image;
}
test('serves a file this device has, with its type and name', () async {
final id = await addImage();
expect(
await repository.fileOf(id),
StoredFile(
bytes: image,
mimeType: 'image/png',
fileName: 'cat.png',
path: cache.pathOf(imageHash),
),
);
expect(server.downloads, isEmpty);
});
test('downloads a file this device lacks, and keeps it', () async {
imageFromElsewhere();
expect((await repository.fileOf('remote-image'))?.bytes, image);
expect(cache.files, {imageHash: image});
await repository.fileOf('remote-image');
expect(server.downloads, ['remote-image']);
});
test('downloads a file once when it is asked for twice at once', () async {
imageFromElsewhere();
await Future.wait<StoredFile?>([
repository.fileOf('remote-image'),
repository.fileOf('remote-image'),
]);
expect(server.downloads, ['remote-image']);
});
test('serves nothing, and keeps nothing, when a download has another '
'content', () async {
imageFromElsewhere();
server.files[imageHash] = Uint8List.fromList([1, 2, 3]);
expect(await repository.fileOf('remote-image'), isNull);
expect(cache.files, isEmpty);
});
test('serves nothing without a row, a file on the server or a '
'connection', () async {
expect(await repository.fileOf('missing'), isNull);
imageFromElsewhere();
server.failCalls = Exception('offline');
expect(await repository.fileOf('remote-image'), isNull);
server
..failCalls = null
..files.clear();
expect(await repository.fileOf('remote-image'), isNull);
});
test('serves nothing for a row whose hash is no SHA-256', () async {
rows.receiveRemote((
id: 'forged',
sha256: '../../sync.sqlite',
mimeType: 'image/png',
byteSize: 1,
payload: encodeAssetPayload(fileName: 'x.png'),
));
expect(await repository.fileOf('forged'), isNull);
expect(server.downloads, isEmpty);
});
});
}

View file

@ -0,0 +1,62 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory folder;
late DirectoryAssetFileCache cache;
final hash = 'a' * 64;
final other = 'b' * 64;
setUp(() async {
folder = await Directory.systemTemp.createTemp('files');
cache = DirectoryAssetFileCache(Directory(p.join(folder.path, 'files')));
});
tearDown(() => folder.delete(recursive: true));
test('keeps a file by its hash, and has none it was not given', () async {
expect(await cache.read(hash), isNull);
await cache.write(hash, Uint8List.fromList([1, 2, 3]));
expect(await cache.read(hash), [1, 2, 3]);
expect(File(cache.pathOf(hash)).readAsBytesSync(), [1, 2, 3]);
expect(
Directory(p.join(folder.path, 'files')).listSync().map((e) => e.path),
[cache.pathOf(hash)],
reason: 'nothing half-written is left beside it',
);
});
test('keeps the file it has when given it again', () async {
await cache.write(hash, Uint8List.fromList([1]));
await cache.write(hash, Uint8List.fromList([2]));
expect(await cache.read(hash), [1]);
});
test('notes which files wait to be uploaded', () async {
expect(await cache.pendingUploads(), isEmpty);
await cache.markPending(hash);
await cache.markPending(other);
await cache.clearPending(hash);
await cache.clearPending('c' * 64);
expect(await cache.pendingUploads(), {other});
});
test('refuses anything but a SHA-256 in a path', () async {
for (final forged in ['../sync.sqlite', 'A' * 64, '${'a' * 63}/', '']) {
expect(() => cache.pathOf(forged), throwsArgumentError);
await expectLater(cache.markPending(forged), throwsArgumentError);
await expectLater(
cache.write(forged, Uint8List.fromList([1])),
throwsArgumentError,
);
}
});
}

View file

@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.dart';
void main() {
test('knows the media types of the images and sounds the app takes', () {
expect(mediaTypeOf('cat.PNG'), 'image/png');
expect(mediaTypeOf('neko.mp3'), 'audio/mpeg');
expect(mediaTypeOf('diagram.svg'), isNull);
expect(mediaTypeOf('noextension'), isNull);
});
test('offers the images and the sounds by their extensions', () {
expect(imageExtensions, containsAll(['png', 'jpg', 'jpeg', 'webp']));
expect(imageExtensions, isNot(contains('mp3')));
expect(soundExtensions, containsAll(['mp3', 'm4a', 'ogg', 'wav']));
expect(soundExtensions, isNot(contains('png')));
});
}

View file

@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import '../../support/eventually.dart';
void main() {
late List<String> deleted;
late DelayedDeletions deletions;
setUp(() {
deleted = [];
deletions = DelayedDeletions(
(id) async => deleted.add(id),
undoWindow: const Duration(milliseconds: 10),
);
});
tearDown(() => deletions.dispose());
test('a held deletion waits past the time to undo, until it is let go: '
'then it takes its time to undo', () async {
deletions.hold('a');
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deletions.pending, {'a'});
expect(deleted, isEmpty);
deletions.delete('a');
expect(deletions.pending, {'a'});
await eventually(() => deletions.pending.isEmpty);
expect(deleted, ['a']);
});
test('a held deletion can be undone', () async {
deletions
..hold('a')
..undo('a');
expect(deletions.pending, isEmpty);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deleted, isEmpty);
});
test('closing drops a held deletion: the item stays', () async {
deletions.hold('a');
await deletions.dispose();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deleted, isEmpty);
deletions = DelayedDeletions((id) async {});
});
}

View file

@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
void main() {
var reads = 0;
late LatestValue<int> latest;
setUp(() {
reads = 0;
latest = LatestValue(() => reads++);
});
tearDown(() => latest.close());
/// Everything [latest] hands one following it, as it comes.
List<int> follow() {
final seen = <int>[];
final subscription = latest.stream.listen(seen.add, onError: (_) {});
addTearDown(subscription.cancel);
return seen;
}
test('one starting to follow before anything was read has it read; those '
'after get the value last read at once, read no more', () async {
final first = follow();
await pumpEventQueue();
expect(reads, 1);
latest.add(1);
final second = follow();
await pumpEventQueue();
expect(first, [1]);
expect(second, [1]);
expect(reads, 1);
latest.add(2);
await pumpEventQueue();
expect(first, [1, 2]);
expect(second, [1, 2]);
});
test(
'after a read failed, one starting to follow has it read again',
() async {
final errors = <Object>[];
final subscription = latest.stream.listen(
(_) {},
onError: errors.add,
);
addTearDown(subscription.cancel);
latest
..add(1)
..addError(Exception('Disk unavailable'), StackTrace.current);
await pumpEventQueue();
expect(errors, hasLength(1));
expect(latest.value, isNull);
final seen = follow();
await pumpEventQueue();
expect(reads, 2);
expect(seen, isEmpty);
latest.add(3);
await pumpEventQueue();
expect(seen, [3]);
},
);
test('keeps nothing while nobody follows it, not even what a read brings '
'after everyone left: one starting to follow has it read anew', () async {
final first = latest.stream.listen((_) {});
await pumpEventQueue();
latest.add(1);
await first.cancel();
expect(latest.hasListener, isFalse);
expect(latest.value, isNull);
latest.add(2);
expect(latest.value, isNull);
final seen = follow();
await pumpEventQueue();
expect(reads, 2);
expect(seen, isEmpty);
});
}

View file

@ -0,0 +1,90 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
void main() {
late StreamController<void> changes;
setUp(() => changes = StreamController<void>.broadcast());
tearDown(() => changes.close());
test('reads now and after changes; a burst during a read makes one more '
'read, not one each', () async {
var reads = 0;
var gate = Completer<void>();
final seen = <int>[];
final subscription = watchReads(
changes.stream,
() async {
final read = ++reads;
await gate.future;
return read;
},
what: 'numbers',
logName: 'Test',
).listen(seen.add);
addTearDown(subscription.cancel);
await pumpEventQueue();
changes
..add(null)
..add(null)
..add(null);
await pumpEventQueue();
final first = gate;
gate = Completer<void>()..complete();
first.complete();
await pumpEventQueue();
expect(seen, [1, 2]);
expect(reads, 2);
});
test('a failed read arrives as an error; the next change reads '
'again', () async {
var fail = true;
final seen = <Object>[];
final subscription = watchReads(
changes.stream,
() async {
if (fail) {
throw StateError('disk unavailable');
}
return 'read';
},
what: 'words',
logName: 'Test',
).listen(seen.add, onError: seen.add);
addTearDown(subscription.cancel);
await pumpEventQueue();
fail = false;
changes.add(null);
await pumpEventQueue();
expect(seen, [isA<StateError>(), 'read']);
});
test('nothing more arrives once cancelled, though a read was under '
'way', () async {
final gate = Completer<void>();
final seen = <String>[];
final subscription = watchReads(
changes.stream,
() async {
await gate.future;
return 'late';
},
what: 'words',
logName: 'Test',
).listen(seen.add);
await pumpEventQueue();
await subscription.cancel();
gate.complete();
await pumpEventQueue();
expect(seen, isEmpty);
});
}

View file

@ -0,0 +1,80 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/chinese/pinyin_input.dart';
void main() {
test('tone numbers become tone marks: on a or e, on the o of ou, else on '
'the last vowel; ü typed as v or u:; the neutral tone unmarked', () {
expect(withToneMarks('ni3hao3'), 'nǐhǎo');
expect(withToneMarks('xue2xi2'), 'xuéxí');
expect(withToneMarks('dou1'), 'dōu');
expect(withToneMarks('gui4'), 'guì');
expect(withToneMarks('liu2'), 'liú');
expect(withToneMarks('lv4'), 'lǜ');
expect(withToneMarks('nu:3'), 'nǚ');
expect(withToneMarks('ma5'), 'ma');
expect(withToneMarks('Bei3jing1'), 'Běijīng');
expect(withToneMarks('xuéxí'), 'xuéxí');
});
test('pinyin compares with its tones, whatever its case, spaces and '
'numbers', () {
expect(pinyinKey('Xué xí'), 'xuéxí');
expect(pinyinKey('xue2 xi2'), 'xuéxí');
expect(pinyinKey("Xi1'an1"), 'xīān');
expect(givenPinyin('xíng, háng'), {'xíng', 'háng'});
expect(givenPinyin('xíng').contains(pinyinKey('xing2')), isTrue);
expect(givenPinyin('xíng').contains(pinyinKey('xing1')), isFalse);
});
test("a saying's comma is part of its reading, as CC-CEDICT writes it "
'between spaces; a comma after a reading separates readings', () {
final saying = givenPinyin('bù pà màn , jiù pà zhàn');
expect(saying.contains(pinyinKey('bu4 pa4 man4 jiu4 pa4 zhan4')), isTrue);
expect(saying.contains(pinyinKey('bu4pa4man4, jiu4pa4zhan4')), isTrue);
expect(saying.contains(pinyinKey('bu4 pa4 man4')), isFalse);
expect(saying.contains(pinyinKey('jiu4 pa4 zhan4')), isFalse);
expect(givenPinyin('xíng,háng'), {'xíng', 'háng'});
});
test("pinyin is told from kana and from words' own letters", () {
expect(isPinyin('xué xí', language: 'zh'), isTrue);
expect(isPinyin('shuō', language: 'zh'), isTrue);
expect(isPinyin('たべる', language: 'zh'), isFalse);
expect(isPinyin('가치', language: 'zh'), isFalse);
expect(isPinyin('', language: 'zh'), isFalse);
});
test('a proper noun, capitalised with its tone mark, is pinyin too', () {
expect(isPinyin('Ōu zhōu', language: 'zh'), isTrue);
expect(isPinyin('Ào mén', language: 'zh'), isTrue);
expect(isPinyin('É yǔ', language: 'zh'), isTrue);
expect(isPinyin('Lǘ', language: 'zh'), isTrue);
expect(givenPinyin('Ōu zhōu').contains(pinyinKey('ou1zhou1')), isTrue);
});
test("only a Chinese word's reading is pinyin, in any script or region; "
"a Japanese word's romaji is not", () {
expect(isPinyin('xué xí', language: 'zh-Hans'), isTrue);
expect(isPinyin('xué xí', language: 'zh-Hant-TW'), isTrue);
expect(isPinyin('Tōkyō', language: 'ja'), isFalse);
expect(isPinyin('toukyou', language: 'ja'), isFalse);
expect(isPinyin('xué xí', language: null), isFalse);
});
test('typing turns a syllable and its tone into the marked syllable, the '
'caret where it was typed', () {
const formatter = PinyinInputFormatter();
final value = formatter.formatEditUpdate(
const TextEditingValue(text: 'ni'),
const TextEditingValue(
text: 'ni3',
selection: TextSelection.collapsed(offset: 3),
),
);
expect(value.text, 'nǐ');
expect(value.selection, const TextSelection.collapsed(offset: 2));
});
}

View file

@ -0,0 +1,386 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
void main() {
/// [source] in [style] as plain text, the italic runs marked `*so*`.
String cite(Source source, CitationStyle style) => [
for (final run in formatCitation(source, style))
if (run.italic) '*${run.text}*' else run.text,
].join();
const pevsner = Source(
authors: 'Jonathan Gregory Pevsner',
year: '2020',
title: 'Bioinformatics and Functional Genomics',
publisher: 'Wiley-Blackwell',
);
const article = Source(
authors: 'Jane Doe',
year: '2021',
title: 'Spacing Effects',
container: 'Journal of Memory',
);
group('a full reference', () {
test('in APA', () {
expect(
cite(pevsner, CitationStyle.apa),
'Pevsner, J. G. (2020). *Bioinformatics and Functional Genomics*. '
'Wiley-Blackwell.',
);
});
test('in MLA', () {
expect(
cite(pevsner, CitationStyle.mla),
'Pevsner, Jonathan Gregory. *Bioinformatics and Functional Genomics*. '
'Wiley-Blackwell, 2020.',
);
});
test('in Chicago', () {
expect(
cite(pevsner, CitationStyle.chicago),
'Pevsner, Jonathan Gregory. *Bioinformatics and Functional Genomics*. '
'Wiley-Blackwell, 2020.',
);
});
});
group('a part of something, as an article in a journal,', () {
test('in APA', () {
expect(
cite(article, CitationStyle.apa),
'Doe, J. (2021). Spacing Effects. *Journal of Memory*.',
);
});
test('in MLA', () {
expect(
cite(article, CitationStyle.mla),
'Doe, Jane. “Spacing Effects.” *Journal of Memory*, 2021.',
);
});
test('in Chicago', () {
expect(
cite(article, CitationStyle.chicago),
'Doe, Jane. “Spacing Effects.” *Journal of Memory*, 2021.',
);
});
test('with a publisher too', () {
const chapter = Source(
authors: 'Jane Doe',
year: '2021',
title: 'Spacing',
container: 'On Memory',
publisher: 'Press',
);
expect(
cite(chapter, CitationStyle.apa),
'Doe, J. (2021). Spacing. *On Memory*. Press.',
);
expect(
cite(chapter, CitationStyle.mla),
'Doe, Jane. “Spacing.” *On Memory*, Press, 2021.',
);
});
});
group('authors', () {
String authors(String names, CitationStyle style) =>
cite(Source(authors: names), style);
test('two, in each style', () {
const two = 'Jonathan Gregory Pevsner; Alan B Smith';
expect(
authors(two, CitationStyle.apa),
'Pevsner, J. G., & Smith, A. B.',
);
expect(
authors(two, CitationStyle.mla),
'Pevsner, Jonathan Gregory, and Alan B Smith.',
);
expect(
authors(two, CitationStyle.chicago),
'Pevsner, Jonathan Gregory, and Alan B Smith.',
);
});
test('one per line counts as one each', () {
expect(
authors('Jonathan Gregory Pevsner\nAlan B Smith', CitationStyle.apa),
'Pevsner, J. G., & Smith, A. B.',
);
});
test('three or more: MLA says et al., Chicago lists up to six', () {
const three = 'A B Cee; D E Eff; G H Igh';
expect(authors(three, CitationStyle.mla), 'Cee, A B, et al.');
expect(
authors(three, CitationStyle.chicago),
'Cee, A B, D E Eff, and G H Igh.',
);
expect(
authors(three, CitationStyle.apa),
'Cee, A. B., Eff, D. E., & Igh, G. H.',
);
final six = [for (var i = 0; i < 6; i++) 'Ann Author$i'].join('; ');
expect(authors(six, CitationStyle.chicago), endsWith('and Ann Author5.'));
// Its 18th edition (2024): from seven on, the first three and et al.
final seven = [for (var i = 0; i < 7; i++) 'Ann Author$i'].join('; ');
expect(
authors(seven, CitationStyle.chicago),
'Author0, Ann, Ann Author1, Ann Author2, et al.',
);
});
test('APA lists up to twenty, then the first nineteen and the last', () {
final many = [for (var i = 0; i < 22; i++) 'Ann Author$i'].join('; ');
final cited = authors(many, CitationStyle.apa);
expect(cited, startsWith('Author0, A., Author1, A.,'));
expect(cited, contains('Author18, A., … Author21, A.'));
expect(cited, isNot(contains('Author19,')));
});
test('particles stay with the family name, suffixes after it', () {
expect(
authors('Martin Luther King, Jr.', CitationStyle.apa),
'King, M. L., Jr.',
reason: 'a comma before the suffix is no inverted name',
);
expect(
authors('Ludwig van Beethoven', CitationStyle.apa),
'van Beethoven, L.',
);
expect(
authors('Martin Luther King Jr.', CitationStyle.apa),
'King, M. L., Jr.',
);
expect(
authors('Martin Luther King Jr.', CitationStyle.mla),
'King, Martin Luther, Jr.',
);
});
test('a hyphenated given name keeps its hyphen in initials', () {
expect(authors('Jean-Paul Sartre', CitationStyle.apa), 'Sartre, J.-P.');
});
test('names already inverted are read as such', () {
expect(
authors('Pevsner, Jonathan Gregory', CitationStyle.apa),
'Pevsner, J. G.',
);
});
test('a single word is a name of its own, as an organisation', () {
expect(authors('UNESCO', CitationStyle.apa), 'UNESCO.');
expect(authors('Aristotle', CitationStyle.mla), 'Aristotle.');
});
test('names written as already cited stay as written', () {
expect(
authors('Doe, J., & Smith, A.', CitationStyle.apa),
'Doe, J., & Smith, A.',
);
expect(
authors('Doe, J., & Smith, A.', CitationStyle.chicago),
'Doe, J., & Smith, A.',
reason: 'no second period',
);
});
});
test('a book of three authors, as the settings show it, in each style', () {
const book = Source(
authors: 'Peter C. Brown\nHenry L. Roediger III\nMark A. McDaniel',
year: '2014',
title: 'Make It Stick',
publisher: 'Belknap Press',
);
expect(
cite(book, CitationStyle.apa),
'Brown, P. C., Roediger, H. L., III, & McDaniel, M. A. (2014). '
'*Make It Stick*. Belknap Press.',
);
expect(
cite(book, CitationStyle.mla),
'Brown, Peter C., et al. *Make It Stick*. Belknap Press, 2014.',
);
expect(
cite(book, CitationStyle.chicago),
'Brown, Peter C., Henry L. Roediger III, and Mark A. McDaniel. '
'*Make It Stick*. Belknap Press, 2014.',
);
});
group('missing parts', () {
test('APA without a year says n.d.', () {
expect(
cite(
const Source(authors: 'Jane Doe', title: 'A Work'),
CitationStyle.apa,
),
'Doe, J. (n.d.). *A Work*.',
);
});
test('APA without authors puts the title first', () {
expect(
cite(
const Source(title: 'A Work', year: '2020', publisher: 'Press'),
CitationStyle.apa,
),
'*A Work*. (2020). Press.',
);
});
test('Chicago without a year says n.d., MLA leaves it out', () {
const undated = Source(authors: 'Jane Doe', title: 'A Work');
expect(cite(undated, CitationStyle.chicago), 'Doe, Jane. *A Work*. n.d.');
expect(cite(undated, CitationStyle.mla), 'Doe, Jane. *A Work*.');
});
test('Chicago without a title or a container', () {
expect(
cite(
const Source(authors: 'Jane Doe', year: '1999'),
CitationStyle.chicago,
),
'Doe, Jane. 1999.',
);
});
test('MLA without a year ends with the container', () {
expect(
cite(
const Source(
authors: 'Jane Doe',
title: 'An Essay',
container: 'A Journal',
),
CitationStyle.mla,
),
'Doe, Jane. “An Essay.” *A Journal*.',
);
});
test('a title ending in a question mark takes no period', () {
expect(
cite(
const Source(authors: 'Jane Doe', year: '2001', title: 'Why Learn?'),
CitationStyle.apa,
),
'Doe, J. (2001). *Why Learn?*',
);
expect(
cite(
const Source(authors: 'Jane Doe', year: '2001', title: 'Why Learn?'),
CitationStyle.mla,
),
'Doe, Jane. *Why Learn?* 2001.',
);
});
test('nothing to cite is no citation', () {
expect(formatCitation(const Source(), CitationStyle.apa), isEmpty);
});
});
group('a source', () {
test('survives JSON, leaving out what it has not got', () {
const source = Source(
authors: 'Jane Doe',
title: 'A Work',
container: 'A Series',
publisher: 'Press',
isbn: '978-3-16-148410-0',
url: 'https://example.org',
);
expect(source.toJson(), {
'authors': 'Jane Doe',
'title': 'A Work',
'container': 'A Series',
'publisher': 'Press',
'isbn': '978-3-16-148410-0',
'url': 'https://example.org',
});
expect(Source.fromJson(source.toJson()), source);
});
test('reads a number as its text, and refuses anything else', () {
expect(
Source.fromJson({'title': 'A Work', 'year': 2020, 'raw': 'Doe 2020'}),
const Source(title: 'A Work', year: '2020', raw: 'Doe 2020'),
);
expect(() => Source.fromJson('A Work'), throwsFormatException);
expect(
() => Source.fromJson({'title': true}),
throwsFormatException,
);
});
test('with nothing in it is empty', () {
expect(const Source().isEmpty, isTrue);
expect(const Source(raw: 'Doe 2020').isEmpty, isFalse);
expect(const Source(title: ' ').trimmed().isEmpty, isTrue);
});
});
group('kinds of source', () {
const video = Source(
kind: SourceKind.video,
authors: 'Tofugu',
year: '2020',
title: 'Learn hiragana',
url: 'https://youtu.be/dQw4w9WgXcQ',
);
test('a video is cited as one, a part of YouTube', () {
expect(
cite(video, CitationStyle.apa),
'Tofugu. (2020). Learn hiragana [Video]. *YouTube*.',
);
expect(
cite(video, CitationStyle.mla),
'Tofugu. “Learn hiragana.” *YouTube*, 2020.',
);
});
test('a website is cited as a page of it', () {
expect(
cite(
const Source(
kind: SourceKind.website,
title: 'Hiragana',
container: 'Wikipedia',
url: 'https://en.wikipedia.org/wiki/Hiragana',
),
CitationStyle.apa,
),
'Hiragana. (n.d.). *Wikipedia*.',
);
});
test('keeps its kind in JSON, a work leaving it out, and reads a kind '
"it doesn't know as a work", () {
expect(video.toJson()['kind'], 'video');
expect(Source.fromJson(video.toJson()), video);
expect(
const Source(title: 'A book').toJson().keys,
isNot(contains('kind')),
);
expect(
Source.fromJson({'kind': 'podcast', 'title': 'Episode 1'}).kind,
SourceKind.work,
);
});
});
}

View file

@ -0,0 +1,41 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
test('survives a round trip and names its schema version', () {
final payload = encodePayload(2, {'title': '日本語'});
expect(decodePayload(payload, newestSchema: 2), {
'schemaVersion': 2,
'title': '日本語',
});
});
test('refuses a schema newer than this app knows', () {
expect(
() => decodePayload(bytes({'schemaVersion': 3}), newestSchema: 2),
throwsFormatException,
);
});
test('refuses what is not a payload, a blanked one included', () {
expect(
() => decodePayload(redactedPayload, newestSchema: 1),
throwsFormatException,
);
expect(
() => decodePayload(bytes({'title': 'no version'}), newestSchema: 1),
throwsFormatException,
);
expect(
() => decodePayload(bytes(['a', 'list']), newestSchema: 1),
throwsFormatException,
);
});
}

View file

@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.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/model/card_content.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_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/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/model/pdf_source.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';
void main() {
const ref = PublishedRef(
publication: 'publication-1',
item: 'item-1',
version: 3,
);
test('every kind of content keeps where a copy of it came from', () {
final card = CardContent(
front: RichContent.plain('猫'),
back: RichContent.plain('cat'),
published: ref,
);
expect(decodeCardPayload(encodeCardPayload(card)), card);
// Normalising, as saving does, keeps it.
expect(card.normalized.published, ref);
final note = NoteContent(body: RichContent.plain('は'), published: ref);
expect(decodeNotePayload(encodeNotePayload(note)), note);
final step = StepContent.empty(StepKind.info).withField(
StepField.published,
ref,
);
expect(decodeStepPayload(encodeStepPayload(step), StepKind.info), step);
final path = LearningPathContent(
title: 'Hiragana',
description: RichContent.empty(),
published: ref,
);
expect(decodeLearningPathPayload(encodeLearningPathPayload(path)), path);
expect(path.withSecondaryTitle('ひらがな').published, ref);
const pdf = PdfSourceContent(
title: 'A book',
fileName: 'book.pdf',
assetId: 'asset-1',
published: ref,
);
expect(decodePdfSourcePayload(encodePdfSourcePayload(pdf)), pdf);
expect(pdf.withTitle('Another').published, ref);
});
test("a copy's top studyset keeps where the published one is", () {
final studyset = StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
published: ref,
publishedAt: 'halkyon.eu/@raisondetredev/japanese',
);
expect(decodeStudysetPayload(encodeStudysetPayload(studyset)), studyset);
expect(
studyset.copyWith(title: 'Nihongo').publishedAt,
'halkyon.eu/@raisondetredev/japanese',
);
});
test("content of the person's own has no origin", () {
final card = CardContent(
front: RichContent.plain('猫'),
back: RichContent.plain('cat'),
);
expect(decodeCardPayload(encodeCardPayload(card)).published, isNull);
});
test('a malformed origin is refused', () {
expect(
() => publishedRefFromJson({'publication': 'p', 'item': 'i'}),
throwsFormatException,
);
expect(
() => publishedRefFromJson({
'publication': 'p',
'item': 'i',
'version': 0,
}),
throwsFormatException,
);
});
}

View file

@ -0,0 +1,340 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
void main() {
/// The editor's JSON for a page with one paragraph per text.
Map<String, Object?> paragraphs(List<String> texts) => {
'document': {
'type': 'page',
'children': [
for (final text in texts)
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
};
group('RichContent', () {
test('without a first heading that repeats a title, whatever its case '
'and spaces; anything else stays', () {
RichContent page(List<Map<String, Object?>> children) => RichContent({
'document': {'type': 'page', 'children': children},
});
Map<String, Object?> heading(String text) => {
'type': 'heading',
'data': {
'level': 1,
'delta': [
{'insert': text},
],
},
};
final text = {
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'In Japanese…'},
],
},
};
expect(
page([heading('What is Kanji?'), text])
.withoutHeading('what is kanji?'),
page([text]),
);
final other = page([heading('Kanji'), text]);
expect(other.withoutHeading('What is Kanji?'), other);
final later = page([text, heading('What is Kanji?')]);
expect(later.withoutHeading('What is Kanji?'), later);
expect(other.withoutHeading(''), other);
});
test('equals content with the same JSON, whatever the key order', () {
final a = RichContent({
'document': {'type': 'page', 'children': <Object?>[]},
});
final b = RichContent({
'document': {'children': <Object?>[], 'type': 'page'},
});
expect(a, b);
expect(a.hashCode, b.hashCode);
});
test('differs when the text differs', () {
expect(
RichContent(paragraphs(['猫'])),
isNot(RichContent(paragraphs(['犬']))),
);
});
test('differs when the format version differs', () {
expect(
RichContent(paragraphs(['猫'])),
isNot(RichContent(paragraphs(['猫']), formatVersion: 0)),
);
});
test('is not affected by later changes to the JSON it was made from', () {
final json = paragraphs(['猫']);
final content = RichContent(json);
final document = json['document']! as Map<String, Object?>;
(document['children']! as List<Object?>).clear();
expect(content, RichContent(paragraphs(['猫'])));
});
test('cannot be changed through its JSON', () {
final content = RichContent(paragraphs(['猫']));
final document = content.editorJson['document']! as Map<String, Object?>;
final children = document['children']! as List<Object?>;
expect(() => document['type'] = 'heading', throwsUnsupportedError);
expect(children.clear, throwsUnsupportedError);
});
test('survives a JSON round trip, Japanese text and marks included', () {
final content = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'heading',
'data': {
'level': 2,
'delta': [
{'insert': '日本語'},
],
},
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '猫',
'attributes': {'bold': true},
},
{'insert': 'は動物です。'},
],
},
},
],
},
});
final stored = jsonEncode(content.toJson());
final read = RichContent.fromJson(
jsonDecode(stored) as Map<String, Object?>,
);
expect(read, content);
});
test('rejects content in a newer format than this app knows', () {
expect(
() => RichContent.fromJson({
'formatVersion': RichContent.currentFormatVersion + 1,
'editor': RichContent.empty().editorJson,
}),
throwsFormatException,
);
});
test('rejects JSON that is not rich content', () {
expect(
() => RichContent.fromJson({'formatVersion': 1, 'editor': 'text'}),
throwsFormatException,
);
expect(
() => RichContent.fromJson({'editor': RichContent.empty().editorJson}),
throwsFormatException,
);
});
});
group('RichContent.plainText', () {
test('is the text without formatting, one line per block', () {
expect(
RichContent(paragraphs(['ひらがな', 'かたかな'])).plainText,
'ひらがな\nかたかな',
);
});
test('joins the pieces of a formatted block', () {
final content = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'big '},
{
'insert': 'cat',
'attributes': {'bold': true},
},
],
},
},
],
},
});
expect(content.plainText, 'big cat');
});
test('is empty for empty content', () {
expect(RichContent.empty().plainText, isEmpty);
});
});
group('files', () {
RichContent blocks(List<Map<String, Object?>> children) => RichContent({
'document': {'type': 'page', 'children': children},
});
test('names the asset rows its images and sounds show', () {
final content = blocks([
{
'type': 'image',
'data': {'url': assetUrl('cat')},
},
{
'type': 'audio',
'data': {'url': assetUrl('neko')},
},
{
'type': 'image',
'data': {'url': 'https://example.com/dog.png'},
},
]);
expect(content.assetIds, {'cat', 'neko'});
expect(content.hasImages, isTrue);
expect(content.hasSounds, isTrue);
});
test('content of only an image or a sound is not blank', () {
expect(
blocks([
{
'type': 'image',
'data': {'url': assetUrl('cat')},
},
]).isBlank,
isFalse,
);
expect(
blocks([
{
'type': 'audio',
'data': {'url': assetUrl('neko')},
},
]).isBlank,
isFalse,
);
expect(RichContent.plain(' ').isBlank, isTrue);
expect(RichContent.empty().isBlank, isTrue);
});
test("a block's text counts as text: a formula, a callout's title, a "
"video's caption, a study button's label, a spoiler's hidden text", () {
final content = blocks([
{
'type': mathBlockType,
'data': {mathFormulaKey: 'E = mc^2'},
},
{
'type': calloutBlockType,
'data': {calloutTitleKey: 'Mind the particle'},
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'は, not わ'},
],
},
},
],
},
{
'type': youtubeBlockType,
'data': {youtubeVideoKey: 'dQw4w9WgXcQ', youtubeCaptionKey: 'Listen'},
},
{
'type': studyButtonBlockType,
'data': {
studyButtonStudysetKey: 'japanese',
studyButtonLabelKey: 'Go',
},
},
{
'type': spoilerBlockType,
'data': {
'delta': [
{'insert': 'ねこ'},
],
},
},
]);
expect(
content.plainText,
'E = mc^2\nMind the particle\nは, not わ\nListen\nGo\nねこ',
);
});
test('a video or a study button is something without any text; an '
'empty formula or callout is nothing', () {
expect(
blocks([
{
'type': youtubeBlockType,
'data': {youtubeVideoKey: 'dQw4w9WgXcQ'},
},
]).isBlank,
isFalse,
);
expect(
blocks([
{
'type': studyButtonBlockType,
'data': {studyButtonStudysetKey: 'japanese'},
},
]).isBlank,
isFalse,
);
expect(
blocks([
{
'type': mathBlockType,
'data': {mathFormulaKey: ' '},
},
{'type': calloutBlockType, 'data': <String, Object?>{}},
]).isBlank,
isTrue,
);
});
test('reads only asset URLs as files of the person', () {
expect(assetIdOf(assetUrl('cat')), 'cat');
expect(assetIdOf('asset:'), isNull);
expect(assetIdOf('https://example.com/cat.png'), isNull);
expect(assetIdOf(42), isNull);
});
});
}

View file

@ -0,0 +1,30 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/country/country_names.dart';
void main() {
test("a map's countries are named by their code, in each language it "
'has', () {
final names = namesIn('''
{"type": "FeatureCollection", "features": [
{"type": "Feature", "properties": {"code": "CH",
"names": {"de": "Schweiz", "en": "Switzerland"}}, "geometry": null},
{"type": "Feature", "properties": {"code": "-99",
"names": {"en": "Somewhere without a code"}}, "geometry": null}
]}
''');
expect(names, {
'CH': {'de': 'Schweiz', 'en': 'Switzerland'},
});
});
testWidgets("the world map's names are read from the app's own", (
tester,
) async {
final name = await tester.runAsync(
() => AssetCountryNames().nameOf('CH', 'de'),
);
expect(name, 'Schweiz');
});
}

View file

@ -0,0 +1,136 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import '../../support/memory_device_settings_store.dart';
void main() {
Future<DeviceSettings> next(
DeviceSettingsBloc bloc,
DeviceSettings expected,
) => bloc.state == expected
? Future.value(expected)
: bloc.stream.firstWhere((state) => state == expected);
test('starts with the settings the device has', () {
final bloc = DeviceSettingsBloc(
MemoryDeviceSettingsStore(),
const DeviceSettings(appearance: Appearance.dark),
);
addTearDown(bloc.close);
expect(bloc.state, const DeviceSettings(appearance: Appearance.dark));
});
test('an appearance chosen applies at once and is kept', () async {
final store = MemoryDeviceSettingsStore();
final bloc = DeviceSettingsBloc(store, const DeviceSettings())
..add(const DeviceSettingsEventAppearanceChosen(Appearance.light));
addTearDown(bloc.close);
await next(bloc, const DeviceSettings(appearance: Appearance.light));
await pumpEventQueue();
expect(store.writes, [const DeviceSettings(appearance: Appearance.light)]);
});
test('a language chosen keeps the appearance, and the device language can '
'be chosen again', () async {
final store = MemoryDeviceSettingsStore();
final bloc =
DeviceSettingsBloc(
store,
const DeviceSettings(appearance: Appearance.dark),
)
..add(const DeviceSettingsEventLanguageChosen('de'))
..add(const DeviceSettingsEventLanguageChosen(null));
addTearDown(bloc.close);
await next(
bloc,
const DeviceSettings(appearance: Appearance.dark, language: 'de'),
);
await next(bloc, const DeviceSettings(appearance: Appearance.dark));
await pumpEventQueue();
expect(
store.writes.last,
const DeviceSettings(appearance: Appearance.dark),
);
});
test('a citation style chosen keeps the rest and is kept', () async {
final store = MemoryDeviceSettingsStore();
const before = DeviceSettings(appearance: Appearance.dark, language: 'de');
final bloc = DeviceSettingsBloc(
store,
before,
)..add(const DeviceSettingsEventCitationStyleChosen(CitationStyle.chicago));
addTearDown(bloc.close);
const after = DeviceSettings(
appearance: Appearance.dark,
language: 'de',
citationStyle: CitationStyle.chicago,
);
await next(bloc, after);
await pumpEventQueue();
expect(store.writes, [after]);
});
test('the Pomodoro timer can be hidden while studying', () async {
final store = MemoryDeviceSettingsStore();
final bloc = DeviceSettingsBloc(store, const DeviceSettings())
..add(const DeviceSettingsEventPomodoroWhileStudyingChosen(false));
addTearDown(bloc.close);
const after = DeviceSettings(pomodoroWhileStudying: false);
await next(bloc, after);
await pumpEventQueue();
expect(store.writes, [after]);
});
test('asking an assistant, turned on, keeps the rest and is kept', () async {
final store = MemoryDeviceSettingsStore();
final bloc = DeviceSettingsBloc(
store,
const DeviceSettings(appearance: Appearance.dark),
)..add(const DeviceSettingsEventAssistantChosen(true));
addTearDown(bloc.close);
const after = DeviceSettings(appearance: Appearance.dark, assistant: true);
await next(bloc, after);
await pumpEventQueue();
expect(store.writes, [after]);
});
test('a copy for another choice keeps asking an assistant', () {
const before = DeviceSettings(assistant: true);
expect(before.withAppearance(Appearance.light).assistant, isTrue);
expect(before.withLanguage('de').assistant, isTrue);
expect(before.withPomodoroWhileStudying(false).assistant, isTrue);
expect(before.withBrowserView('a', detailed: true).assistant, isTrue);
});
test('the search engine chosen, and back to the default, keep the rest', () {
const before = DeviceSettings(
assistant: true,
assistantTarget: AssistantTarget.web,
language: 'de',
);
final custom = before.withSearchEngine(
'custom',
customUrl: 'https://a.example/?q=%s',
);
final back = custom.withSearchEngine(null);
expect(custom.searchEngine, 'custom');
expect(custom.assistantTarget, AssistantTarget.web);
expect(custom.language, 'de');
expect(back.searchEngine, isNull);
expect(back.customSearchUrl, isNull);
expect(back.withLanguage(null).language, isNull);
expect(back.withLanguage(null).assistant, isTrue);
});
}

View file

@ -0,0 +1,152 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
void main() {
late Directory folder;
late File file;
late FileDeviceSettingsStore store;
setUp(() async {
folder = await Directory.systemTemp.createTemp('device_settings_test');
file = File('${folder.path}/device_settings.json');
store = FileDeviceSettingsStore(file);
});
tearDown(() => folder.delete(recursive: true));
test("a device that never chose anything follows the device's theme and "
'language', () async {
expect(await store.read(), const DeviceSettings());
});
test('keeps what was chosen', () async {
await store.write(
const DeviceSettings(appearance: Appearance.dark, language: 'de'),
);
expect(
await FileDeviceSettingsStore(file).read(),
const DeviceSettings(appearance: Appearance.dark, language: 'de'),
);
});
test('keeps the citation style, APA until another is chosen', () async {
expect((await store.read()).citationStyle, CitationStyle.apa);
await store.write(const DeviceSettings(citationStyle: CitationStyle.mla));
expect(
await FileDeviceSettingsStore(file).read(),
const DeviceSettings(citationStyle: CitationStyle.mla),
);
});
test('shows the Pomodoro timer while studying until told not to', () async {
expect((await store.read()).pomodoroWhileStudying, isTrue);
await store.write(const DeviceSettings(pomodoroWhileStudying: false));
expect(
(await FileDeviceSettingsStore(file).read()).pomodoroWhileStudying,
isFalse,
);
});
test(
"doesn't ask an assistant until turned on, then keeps it, and the rest",
() async {
expect((await store.read()).assistant, isFalse);
await store.write(
const DeviceSettings(assistant: true, pomodoroWhileStudying: false),
);
final read = await FileDeviceSettingsStore(file).read();
expect(read.assistant, isTrue);
expect(read.pomodoroWhileStudying, isFalse);
},
);
test('keeps where questions go, the assistant and the search engine, '
'their own addresses too', () async {
final first = await store.read();
expect(first.assistantTarget, AssistantTarget.assistant);
expect(first.aiAssistant, isNull);
expect(first.searchEngine, isNull);
await store.write(
const DeviceSettings(
assistantTarget: AssistantTarget.web,
aiAssistant: 'custom',
customAssistantUrl: 'https://chat.example.org/?q=%s',
searchEngine: 'custom',
customSearchUrl: 'https://search.example.org/?q=%s',
),
);
final read = await FileDeviceSettingsStore(file).read();
expect(read.assistantTarget, AssistantTarget.web);
expect(read.aiAssistant, 'custom');
expect(read.customAssistantUrl, 'https://chat.example.org/?q=%s');
expect(read.searchEngine, 'custom');
expect(read.customSearchUrl, 'https://search.example.org/?q=%s');
});
test('questions a device sent to Claude, the only assistant before, go to '
'the assistant chosen', () async {
file.writeAsStringSync('{"assistant": true, "assistantTarget": "claude"}');
final read = await FileDeviceSettingsStore(file).read();
expect(read.assistantTarget, AssistantTarget.assistant);
expect(read.aiAssistant, isNull);
});
test('keeps a return to the device language', () async {
await store.write(const DeviceSettings(language: 'en-US'));
await store.write(const DeviceSettings(appearance: Appearance.light));
expect(
await store.read(),
const DeviceSettings(appearance: Appearance.light),
);
});
test("keeps the PDF sidebar's share of the room, a share only", () async {
expect((await store.read()).pdfSidebarShare, isNull);
await store.write(const DeviceSettings().withPdfSidebarShare(0.4));
expect((await FileDeviceSettingsStore(file).read()).pdfSidebarShare, 0.4);
for (final share in ['1.5', '0', '"wide"']) {
await file.writeAsString('{"pdfSidebarShare": $share}');
expect((await store.read()).pdfSidebarShare, isNull, reason: share);
}
});
test('leaves no temporary file behind', () async {
await store.write(const DeviceSettings(appearance: Appearance.dark));
expect(
folder.listSync().map((entry) => entry.uri.pathSegments.last),
['device_settings.json'],
);
});
test(
'a damaged or unknown file falls back to what the device does',
() async {
for (final content in [
'not json',
'[1, 2]',
'{"appearance": "sepia", "language": 3, "citationStyle": "harvard"}',
]) {
await file.writeAsString(content);
expect(await store.read(), const DeviceSettings(), reason: content);
}
},
);
}

View file

@ -0,0 +1,334 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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_file_picker.dart';
import '../../support/fake_note_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 FakeFilePicker files;
late FakeAssetGateway assets;
setUp(() {
files = FakeFilePicker();
assets = FakeAssetGateway();
});
/// Opens a new note's editor and puts the caret into its body.
Future<EditorState> writeNote(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
noteGateway: FakeNoteGateway(),
files: files,
assetGateway: assets,
assetFiles: MemoryAssetFileCache(),
),
location: RoutePaths.newNote('japanese'),
);
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.byType(AppFlowyEditor));
await tester.pump(const Duration(seconds: 1));
return tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.editorState;
}
/// Chooses [block] from the Insert menu.
Future<void> insert(WidgetTester tester, String block) async {
await tester.tap(find.byTooltip('Insert'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text(block));
await tester.pumpAndSettle();
await tester.tap(find.text(block));
await tester.pumpAndSettle();
}
List<String> types(EditorState editorState) => [
for (final node in editorState.document.root.children) node.type,
];
testWidgets('Insert lists the blocks by kind; a divider takes the place of '
'the empty line, with a line after it to go on typing', (tester) async {
final editorState = await writeNote(tester);
await tester.tap(find.byTooltip('Insert'));
await tester.pumpAndSettle();
for (final text in [
'Text',
'Heading 1',
'Checklist',
'Blocks',
'Table',
'Media',
'Image',
'Sound',
]) {
expect(find.text(text), findsOneWidget, reason: text);
}
await tester.ensureVisible(find.text('Divider'));
await tester.pumpAndSettle();
await tester.tap(find.text('Divider'));
await tester.pumpAndSettle();
expect(types(editorState), ['divider', 'paragraph']);
expect(editorState.selection, Selection.collapsed(Position(path: [1])));
});
testWidgets('a heading becomes the line the caret is on, when empty', (
tester,
) async {
final editorState = await writeNote(tester);
await insert(tester, 'Heading 2');
expect(types(editorState), ['heading']);
expect(editorState.document.root.children.single.attributes['level'], 2);
});
testWidgets('a table has two rows of two cells, the caret in the first, '
'and a line after it', (tester) async {
final editorState = await writeNote(tester);
await insert(tester, 'Table');
expect(types(editorState), ['table', 'paragraph']);
expect(editorState.document.root.children.first.children, hasLength(4));
expect(
editorState.selection,
Selection.collapsed(Position(path: [0, 0, 0])),
);
});
testWidgets("an image picked becomes a file of the person's, shown where "
'the caret was', (tester) async {
final editorState = await writeNote(tester);
files.next = (
name: 'neko.png',
bytes: Uint8List.fromList(utf8.encode('an image')),
);
await insert(tester, 'Image');
expect(files.asked.single, contains('png'));
expect(types(editorState), ['image', 'paragraph']);
final url = editorState.document.root.children.first.attributes['url'];
expect(url, assetUrl(assets.rows.single.id));
expect(assets.rows.single.mimeType, 'image/png');
});
testWidgets('a sound picked becomes a file of the person, played where the '
'caret was', (tester) async {
final editorState = await writeNote(tester);
files.next = (
name: 'neko.mp3',
bytes: Uint8List.fromList(utf8.encode('a sound')),
);
await insert(tester, 'Sound');
expect(files.asked.single, contains('mp3'));
expect(types(editorState), ['audio', 'paragraph']);
expect(find.text('Play sound'), findsOneWidget);
});
testWidgets("a file that isn't an image is refused, and says so", (
tester,
) async {
final editorState = await writeNote(tester);
files.next = (
name: 'notes.txt',
bytes: Uint8List.fromList(utf8.encode('text')),
);
await insert(tester, 'Image');
expect(find.text("This file isn't an image the app can show."), findsOne);
expect(types(editorState), ['paragraph']);
expect(assets.rows, isEmpty);
});
testWidgets('the remove button takes a block out', (tester) async {
final editorState = await writeNote(tester);
await insert(tester, 'Sound');
expect(types(editorState), ['paragraph']);
files.next = (
name: 'neko.mp3',
bytes: Uint8List.fromList(utf8.encode('a sound')),
);
await insert(tester, 'Sound');
await tester.tap(find.byTooltip('Remove sound'));
await tester.pump(const Duration(seconds: 1));
expect(types(editorState), ['paragraph']);
});
testWidgets('/ opens the same blocks on desktops, translated', (
tester,
) async {
final editorState = await writeNote(tester);
final slash = tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.characterShortcutEvents
.singleWhere((event) => event.character == '/');
await slash.handler(editorState);
await tester.pumpAndSettle();
expect(find.text('Heading 1'), findsOneWidget);
await tester.tap(find.text('Heading 2'));
await tester.pumpAndSettle();
expect(types(editorState), ['heading']);
expect(editorState.document.root.children.single.attributes['level'], 2);
});
testWidgets("the / menu takes the theme's colours", (tester) async {
final editorState = await writeNote(tester);
final slash = tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.characterShortcutEvents
.singleWhere((event) => event.character == '/');
await slash.handler(editorState);
await tester.pumpAndSettle();
final colors = Theme.of(tester.element(find.byType(AppFlowyEditor)))
.colorScheme;
final menu = tester.widget<SelectionMenuWidget>(
find.byType(SelectionMenuWidget),
);
expect(
menu.selectionMenuStyle.selectionMenuBackgroundColor,
colors.surfaceContainer,
);
expect(
menu.selectionMenuStyle.selectionMenuItemTextColor,
colors.onSurface,
);
// Closed again, as the editor expects when it goes.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(SelectionMenuWidget), findsNothing);
});
testWidgets('a table shows its cells where it is shown read-only', (
tester,
) async {
final table = TableNode.fromList([
['は', 'topic'],
['が', 'subject'],
]).node;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: appLocalizationsDelegates,
home: Scaffold(
body: RichContentView(
content: Document(
root: pageNode(children: [table]),
).toRichContent(),
),
),
),
);
await tester.pump(const Duration(seconds: 1));
for (final text in ['は', 'topic', 'が', 'subject']) {
expect(find.text(text, findRichText: true), findsOneWidget);
}
});
group('a block this app does not know', () {
final hologram = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'hologram',
'data': {'beam': 3},
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'cat'},
],
},
},
],
},
});
Future<void> pump(WidgetTester tester, Widget child) => tester.pumpWidget(
MaterialApp(
localizationsDelegates: appLocalizationsDelegates,
home: Scaffold(body: child),
),
);
testWidgets('says it needs a newer app where it is shown', (tester) async {
await pump(tester, RichContentView(content: hologram));
await tester.pump(const Duration(seconds: 1));
expect(
find.text('This part needs a newer version of the app.'),
findsOneWidget,
);
});
testWidgets('stays as it is while the rest is edited', (tester) async {
late RichContent latest;
await pump(
tester,
SizedBox(
height: 400,
child: RichContentEditor(
initialContent: hologram,
onChanged: (content) => latest = content,
),
),
);
await tester.pump(const Duration(seconds: 1));
final editorState = tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.editorState;
final transaction = editorState.transaction
..insertText(editorState.getNodeAtPath([1])!, 3, 's');
await editorState.apply(transaction);
await tester.pump(const Duration(seconds: 1));
final document = latest.editorJson['document']! as Map<String, Object?>;
expect((document['children']! as List).first, {
'type': 'hologram',
'data': {'beam': 3},
});
expect(latest.plainText, 'cats');
});
});
}

View file

@ -0,0 +1,50 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:material_ui/material_ui.dart';
void main() {
final german = lookupAppLocalizations(const Locale('de'));
/// The placeholder an empty [node] shows, among the builders for [l10n].
String placeholderOf(Node node, AppLocalizations l10n) => buildersWithUnknown(
coreBlocks,
l10n,
)[node.type]!.configuration.placeholderText(node);
test("the editor's own text blocks say what they are in the app's "
'language', () {
expect(placeholderOf(bulletedListNode(), german), german.blockListItem);
expect(placeholderOf(numberedListNode(), german), german.blockListItem);
expect(
placeholderOf(todoListNode(checked: false), german),
german.blockChecklistItem,
);
expect(placeholderOf(quoteNode(), german), german.blockQuote);
expect(
placeholderOf(headingNode(level: 2), german),
german.blockHeading(2),
);
});
test('an empty line hints at the / menu on the desktop only, where it '
'is', () {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
expect(placeholderOf(paragraphNode(), german), german.editorSlashHint);
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(placeholderOf(paragraphNode(), german).trim(), isEmpty);
debugDefaultTargetPlatformOverride = null;
});
test('the blocks keep what else the editor set them up with', () {
final todo =
buildersWithUnknown(coreBlocks, german)[TodoListBlockKeys.type]!
as TodoListBlockComponentBuilder;
expect(todo.toggleChildrenTriggers, isNotEmpty);
});
}

Some files were not shown because too many files have changed in this diff Show more