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
252 changed files with 0 additions and 0 deletions
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
238
apps/learn/halkyon_learn_flutter/test/app/long_names_test.dart
Normal file
238
apps/learn/halkyon_learn_flutter/test/app/long_names_test.dart
Normal 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';
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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')));
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue