chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
5 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,371 @@
|
|||
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/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/card/widget/composer/card_composer_screen.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_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_dictionary_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeCardGateway cardGateway;
|
||||
late CardRepository cards;
|
||||
|
||||
setUp(() {
|
||||
cardGateway = FakeCardGateway();
|
||||
cards = CardRepository(cardGateway);
|
||||
});
|
||||
tearDown(() => cards.dispose());
|
||||
|
||||
/// The studyset `japanese` with a word, a question and another word, on
|
||||
/// a window of [width].
|
||||
Future<void> show(
|
||||
WidgetTester tester, {
|
||||
double width = 1400,
|
||||
String? location,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
);
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: RichContent.plain('食べる'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'たべる',
|
||||
meanings: const ['to eat'],
|
||||
),
|
||||
);
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: RichContent.plain('Capital of Japan?'),
|
||||
back: RichContent.plain('Tokyo'),
|
||||
),
|
||||
);
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'ねこ',
|
||||
meanings: const ['cat'],
|
||||
),
|
||||
);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(studysetGateway: studysets, cardGateway: cardGateway),
|
||||
location: location ?? RoutePaths.cardBrowser('japanese'),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("Browse beside the flashcards' tab opens the browser, every "
|
||||
'card a tile with its two sides', (tester) async {
|
||||
await show(tester, location: RoutePaths.studyset('japanese'));
|
||||
|
||||
await tester.tap(find.text('Browse'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Japanese — flashcards'), findsOneWidget);
|
||||
// How many, beside the name.
|
||||
expect(
|
||||
find.descendant(of: find.byType(CountBadge), matching: find.text('3')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Tokyo', findRichText: true), findsOneWidget);
|
||||
expect(find.text('to eat'), findsOneWidget);
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('with the sub-studysets, a line below the name says so', (
|
||||
tester,
|
||||
) async {
|
||||
await show(
|
||||
tester,
|
||||
location: RoutePaths.cardBrowser('japanese', subtree: true),
|
||||
);
|
||||
|
||||
expect(find.text('Japanese — flashcards'), findsOneWidget);
|
||||
expect(find.text('With its sub-studysets'), findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(find.text('With its sub-studysets')).top,
|
||||
greaterThanOrEqualTo(
|
||||
tester.getRect(find.text('Japanese — flashcards')).bottom,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a search narrows the cards, by reading too; one finding '
|
||||
'nothing offers to clear it', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'neko');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
expect(find.text('to eat'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'zebra');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No flashcards match'), findsOneWidget);
|
||||
await tester.tap(find.text('Clear search & filters'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('to eat'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the filter sheet narrows them to a kind', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Filter'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Words only'));
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Tokyo', findRichText: true), findsNothing);
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a character shows its meanings and readings, and the filter '
|
||||
'finds characters alone', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.character,
|
||||
front: RichContent.plain('食'),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['eat'],
|
||||
onReadings: const ['ショク'],
|
||||
kunReadings: const ['た.べる'],
|
||||
),
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
final answer = find.text('eat\nOn: ショク\nKun: た.べる');
|
||||
expect(answer, findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Filter'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Characters only'));
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('cat'), findsNothing);
|
||||
expect(answer, findsOneWidget);
|
||||
|
||||
// A kun reading is found without its okurigana dot.
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'taberu');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(answer, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('cards behind the dictionary say so, and take its '
|
||||
'corrections at a tap', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await cards.create(
|
||||
'words',
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: RichContent.plain('食べる'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'たべる',
|
||||
senses: const [
|
||||
VocabularySense(glosses: ['to eat']),
|
||||
],
|
||||
entry: (source: 'jmdict', id: '1358280'),
|
||||
entryVersion: 'v1',
|
||||
).normalized,
|
||||
);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'words',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Words',
|
||||
description: RichContent.empty(),
|
||||
frontLanguage: 'ja',
|
||||
backLanguage: 'en',
|
||||
),
|
||||
),
|
||||
),
|
||||
cardGateway: cardGateway,
|
||||
dictionaryGateway: FakeDictionaryGateway(
|
||||
words: [
|
||||
word(
|
||||
'1358280',
|
||||
'食べる',
|
||||
reading: 'たべる',
|
||||
glosses: ['to eat', 'to consume'],
|
||||
version: 'v2',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
location: RoutePaths.cardBrowser('words'),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'1 card is behind the dictionary: its entry was corrected since it '
|
||||
'was made.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Take the corrections'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('1 card updated.'), findsOneWidget);
|
||||
expect(find.text('Take the corrections'), findsNothing);
|
||||
final updated = (await cards.cardsOf('words')).single.content;
|
||||
expect(updated.meanings, ['to eat', 'to consume']);
|
||||
expect(updated.entryVersion, 'v2');
|
||||
});
|
||||
|
||||
testWidgets('cards chosen are deleted together, and Undo brings them '
|
||||
'back', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Select'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('to eat'));
|
||||
await tester.tap(find.text('cat'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('2 selected'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2 flashcards deleted'), findsOneWidget);
|
||||
expect(find.text('cat'), findsNothing);
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the detail view shows one card beside the list, the next a '
|
||||
'tap away; it stays chosen on this device', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Detail'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Next'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Next'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Tokyo', findRichText: true), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('in the detail view the list follows the card shown, down '
|
||||
'and back up', (tester) async {
|
||||
for (var i = 1; i <= 40; i++) {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: RichContent.plain('Question $i'),
|
||||
back: RichContent.plain('Answer $i'),
|
||||
),
|
||||
);
|
||||
}
|
||||
await show(tester);
|
||||
await tester.tap(find.byTooltip('Detail'));
|
||||
await tester.pumpAndSettle();
|
||||
final list = tester.getRect(find.byType(ListView).first);
|
||||
void expectInView(String front) {
|
||||
final tile = tester.getRect(find.text(front).first);
|
||||
expect(tile.top, greaterThanOrEqualTo(list.top));
|
||||
expect(tile.bottom, lessThanOrEqualTo(list.bottom));
|
||||
}
|
||||
|
||||
// From the first of the 40, made before the three of every test.
|
||||
for (var i = 0; i < 38; i++) {
|
||||
await tester.tap(find.byTooltip('Next'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expectInView('Question 39');
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await tester.tap(find.byTooltip('Previous'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expectInView('Question 9');
|
||||
});
|
||||
|
||||
testWidgets('on a phone, what can be done to the chosen cards sits at '
|
||||
"the screen's foot, and one card shows above a strip of them", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, width: 400);
|
||||
|
||||
await tester.tap(find.byTooltip('Select'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Move'), findsOneWidget);
|
||||
expect(find.text('Add tags'), findsOneWidget);
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('Detail'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PageView), findsOneWidget);
|
||||
expect(find.byTooltip('Next'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('from the expanded window on, what can be done to the chosen '
|
||||
"cards ends at the row's end", (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Select'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final done = tester.getRect(find.widgetWithText(FilledButton, 'Done'));
|
||||
final row = tester.getRect(find.byTooltip('Select').first);
|
||||
expect(done.right, moreOrLessEquals(row.right, epsilon: 4));
|
||||
});
|
||||
|
||||
testWidgets('on a phone in the detail view, choosing cards shows the '
|
||||
'grid, where a tap chooses a card rather than opening it; Done '
|
||||
'returns to the detail', (tester) async {
|
||||
await show(tester, width: 400);
|
||||
await tester.tap(find.byTooltip('Detail'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('Select'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PageView), findsNothing);
|
||||
await tester.tap(find.text('cat'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('1 selected'), findsOneWidget);
|
||||
expect(find.byType(CardComposerScreen), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PageView), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,777 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
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';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.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_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/loaded_maps.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
/// Rich content with one paragraph of [text].
|
||||
RichContent paragraph(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
CardContent card(String front, String back) =>
|
||||
CardContent(front: paragraph(front), back: paragraph(back));
|
||||
|
||||
/// [count] paragraphs, `Line 1` on.
|
||||
RichContent lines(int count) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'Line $i'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
late FakeCardGateway gateway;
|
||||
late CardRepository cards;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeCardGateway();
|
||||
cards = CardRepository(gateway);
|
||||
});
|
||||
tearDown(() => cards.dispose());
|
||||
|
||||
/// Lets the editors' timers run out and the screen settle.
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// [text] within a field of the card, not the list of cards beside it.
|
||||
Finder inField(String text) => find.descendant(
|
||||
of: find.byType(CardFace),
|
||||
matching: find.text(text, findRichText: true),
|
||||
);
|
||||
|
||||
/// Opens the studyset `japanese`, with the cards the test gave [cards], in
|
||||
/// a window of [size].
|
||||
Future<void> showStudyset(
|
||||
WidgetTester tester, {
|
||||
GeoMapRepository? maps,
|
||||
Size size = const Size(1400, 1600),
|
||||
String? frontLanguage,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
frontLanguage: frontLanguage,
|
||||
),
|
||||
),
|
||||
);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: gateway,
|
||||
maps: maps,
|
||||
),
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// Opens the card with [front] in the composer.
|
||||
Future<void> open(WidgetTester tester, String front) async {
|
||||
await tester.tap(find.text(front));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// Opens the map from a map question's answer.
|
||||
Future<void> pickOnMap(WidgetTester tester) async {
|
||||
await tester.tap(find.byType(ChevronField));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// Chooses [item] of the card's ⋮ menu.
|
||||
Future<void> action(WidgetTester tester, String item) async {
|
||||
await tester.tap(find.byTooltip('More actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(item));
|
||||
// A menu item acts once the menu has closed, a frame later.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
testWidgets('Create flashcards opens the composer on a new card of a '
|
||||
'studyset without any; leaving it untouched asks nothing', (
|
||||
tester,
|
||||
) async {
|
||||
await showStudyset(tester);
|
||||
|
||||
await tester.tap(find.text('Add material'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Create flashcards'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Create flashcards for "Japanese"'), findsOneWidget);
|
||||
expect(find.text('Front'), findsOneWidget);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
expect(gateway.written, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a card opens in the composer among the others; an edit is '
|
||||
'saved on leaving it', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await cards.create('japanese', card('犬', 'dog'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '犬');
|
||||
|
||||
// Both cards are in the stack; the one tapped is open.
|
||||
expect(find.text('dog'), findsWidgets);
|
||||
expect(find.text('Edit flashcards in "Japanese"'), findsOneWidget);
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, 'いぬ');
|
||||
await tester.enterText(find.byType(TextField).at(1), 'dog');
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).last.content;
|
||||
expect(saved.type, CardType.vocabulary);
|
||||
expect(saved.reading, 'いぬ');
|
||||
expect(saved.meanings, ['dog']);
|
||||
expect(saved.back, RichContent.empty());
|
||||
expect(find.text('All changes saved'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hints and an explanation are written beside the card: '
|
||||
'removed one by one, added, kept unless blank', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: paragraph('食べる'),
|
||||
back: paragraph('to eat'),
|
||||
hints: [paragraph('a verb'), paragraph('ichidan')],
|
||||
explanation: paragraph('食 is food'),
|
||||
),
|
||||
);
|
||||
await showStudyset(tester);
|
||||
await open(tester, '食べる');
|
||||
expect(find.text('Hint 2'), findsOneWidget);
|
||||
expect(find.text('Explanation'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Remove hint').first);
|
||||
await settle(tester);
|
||||
expect(find.text('Hint 2'), findsNothing);
|
||||
expect(
|
||||
find.textContaining('ichidan', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Remove explanation'));
|
||||
await settle(tester);
|
||||
expect(find.text('Explanation'), findsNothing);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Add hint'));
|
||||
await settle(tester);
|
||||
expect(find.text('Hint 2'), findsOneWidget);
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).first.content;
|
||||
expect(saved.hints, [paragraph('ichidan')]);
|
||||
expect(saved.explanation, isNull);
|
||||
});
|
||||
|
||||
testWidgets("a word's senses are written as such, their grammar kept, its "
|
||||
'meanings taken from them', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: paragraph('食べる'),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['to eat', 'to live on'],
|
||||
senses: const [
|
||||
VocabularySense(glosses: ['to eat'], pos: ['v1']),
|
||||
VocabularySense(glosses: ['to live on']),
|
||||
],
|
||||
),
|
||||
);
|
||||
await showStudyset(tester);
|
||||
await open(tester, '食べる');
|
||||
expect(find.text('Ichidan'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Remove sense').first);
|
||||
await settle(tester);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'to live on'),
|
||||
'to make a living',
|
||||
);
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).first.content;
|
||||
expect(saved.senses, const [
|
||||
VocabularySense(glosses: ['to make a living']),
|
||||
]);
|
||||
expect(saved.meanings, ['to make a living']);
|
||||
});
|
||||
|
||||
testWidgets('New flashcard opens the new card, also while the changed one '
|
||||
'it leaves is being saved', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, 'ねこ');
|
||||
await tester.enterText(find.byType(TextField).at(1), 'cat');
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
// The new card took the type of the one before, and none of its fields.
|
||||
expect(find.text('Reading'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField).first).controller?.text,
|
||||
isEmpty,
|
||||
);
|
||||
expect((await cards.cardsOf('japanese')).single.content.reading, 'ねこ');
|
||||
});
|
||||
|
||||
testWidgets('Duplicate opens the copy, also while the changed card it '
|
||||
'leaves is being saved', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, 'ねこ');
|
||||
await tester.enterText(find.byType(TextField).at(1), 'cat');
|
||||
await action(tester, 'Duplicate');
|
||||
// What is written now goes into the copy.
|
||||
await tester.enterText(find.byType(TextField).first, 'こねこ');
|
||||
await tester.enterText(find.byType(TextField).at(1), 'kitten');
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
expect(
|
||||
[
|
||||
for (final card in await cards.cardsOf('japanese'))
|
||||
card.content.reading,
|
||||
],
|
||||
unorderedEquals(['ねこ', 'こねこ']),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a tap on a field opens its editor, which stays above the '
|
||||
'keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester, size: const Size(412, 915));
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('cat', findRichText: true));
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 400);
|
||||
await settle(tester);
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(RichContentEditor)).dy,
|
||||
lessThanOrEqualTo(915 - 400),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a field being written has the format bar, Insert first, so '
|
||||
'blocks go into cards on phones too', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester, size: const Size(412, 915));
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('cat', findRichText: true));
|
||||
await settle(tester);
|
||||
expect(find.byTooltip('Bold'), findsOneWidget);
|
||||
// A short field opens as low as a field gets, the bar within.
|
||||
expect(
|
||||
tester.getSize(find.byType(RichContentEditor)).height,
|
||||
CardFace.defaultMinEditorHeight,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Insert'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('Formula'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Formula'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'x^2');
|
||||
await tester.tap(find.text('Save'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
final back = (await cards.cardsOf('japanese')).single.content.back;
|
||||
final blocks =
|
||||
(back.editorJson['document']! as Map<String, Object?>)['children']!
|
||||
as List<Object?>;
|
||||
expect(blocks, contains(containsPair('type', mathBlockType)));
|
||||
});
|
||||
|
||||
testWidgets('the / menu takes the focus without closing its field, so the '
|
||||
'block chosen goes into the card', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester, size: const Size(412, 915));
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('cat', findRichText: true));
|
||||
await settle(tester);
|
||||
final editor = tester.widget<AppFlowyEditor>(
|
||||
find.descendant(
|
||||
of: find.byType(RichContentEditor),
|
||||
matching: find.byType(AppFlowyEditor),
|
||||
),
|
||||
);
|
||||
final slash = editor.characterShortcutEvents.singleWhere(
|
||||
(event) => event.character == '/',
|
||||
);
|
||||
await slash.handler(editor.editorState);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
|
||||
// The menu builds only the items near what it shows.
|
||||
final menu = find.descendant(
|
||||
of: find.byType(SelectionMenuWidget),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
await tester.scrollUntilVisible(find.text('Formula'), 40, scrollable: menu);
|
||||
await tester.ensureVisible(find.text('Formula'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Formula'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SelectionMenuWidget), findsNothing);
|
||||
await tester.enterText(find.byType(TextField), 'x^2');
|
||||
await tester.tap(find.text('Save'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
final back = (await cards.cardsOf('japanese')).single.content.back;
|
||||
final blocks =
|
||||
(back.editorJson['document']! as Map<String, Object?>)['children']!
|
||||
as List<Object?>;
|
||||
expect(blocks, contains(containsPair('type', mathBlockType)));
|
||||
});
|
||||
|
||||
testWidgets('the card types stay on one row on a phone, scrolling '
|
||||
'sideways, none of their names broken', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester, size: const Size(412, 915));
|
||||
await open(tester, '猫');
|
||||
|
||||
const types = ['Flashcard', 'Vocabulary', 'Matching', 'Map', 'Character'];
|
||||
for (final type in types) {
|
||||
final name = tester.renderObject<RenderParagraph>(find.text(type));
|
||||
expect(
|
||||
name.size.width,
|
||||
moreOrLessEquals(name.getMaxIntrinsicWidth(double.infinity)),
|
||||
reason: type,
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(find.text(type)).dy,
|
||||
tester.getCenter(find.text(types.first)).dy,
|
||||
reason: type,
|
||||
);
|
||||
}
|
||||
await tester.ensureVisible(find.text('Map'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Map').hitTestable(), findsOneWidget);
|
||||
await tester.ensureVisible(find.text('Vocabulary'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
await settle(tester);
|
||||
expect(find.text('Reading'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a field too tall for its preview fades out at the bottom; '
|
||||
'one that fits does not', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('猫'), back: lines(20)),
|
||||
);
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
Finder fadeIn(Finder face) =>
|
||||
find.descendant(of: face, matching: find.byType(ShaderMask));
|
||||
expect(fadeIn(find.byType(CardFace).first), findsNothing);
|
||||
expect(fadeIn(find.byType(CardFace).last), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a second answer, added from the open answer, makes a '
|
||||
'multiple-choice card, one answer right, the new one open to write', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('Capital of Japan?', 'Tokyo'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, 'Capital of Japan?');
|
||||
// Offered while the answer is written.
|
||||
expect(find.text('Add answer'), findsNothing);
|
||||
await tester.tap(inField('Tokyo'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Add answer'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Answers'), findsOneWidget);
|
||||
expect(find.text('Correct answer'), findsOneWidget);
|
||||
expect(find.text('Wrong answer'), findsOneWidget);
|
||||
// The new answer is the one open, framed by its card alone.
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byType(RichContentEditor),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is CardFace && widget.accent != null,
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(inField('Tokyo'), findsOneWidget);
|
||||
// The only right answer can't turn wrong.
|
||||
await tester.tap(find.byType(Switch).first);
|
||||
await settle(tester);
|
||||
expect(find.text('Correct answer'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a field being written closes at a click elsewhere, and at a '
|
||||
"finger's tap, but not as a finger scrolls the page", (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
final outside = tester.getCenter(find.text('Back'));
|
||||
|
||||
await tester.tap(inField('cat'));
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
await tester.tapAt(outside, kind: PointerDeviceKind.mouse);
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsNothing);
|
||||
|
||||
await tester.tap(inField('cat'));
|
||||
await settle(tester);
|
||||
await tester.dragFrom(outside, const Offset(0, -40));
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
await tester.tapAt(tester.getCenter(find.text('Back')));
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a field closed after writing in it is not left lit as if '
|
||||
'the pointer were over it', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(
|
||||
location: tester.getCenter(inField('cat')),
|
||||
);
|
||||
await tester.pump();
|
||||
await mouse.down(tester.getCenter(inField('cat')));
|
||||
await mouse.up();
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
|
||||
// Into the question, the pointer leaving the answer on its way.
|
||||
final front = tester.getCenter(inField('猫'));
|
||||
await mouse.moveTo(front);
|
||||
await mouse.down(front);
|
||||
await mouse.up();
|
||||
await settle(tester);
|
||||
|
||||
final answer = find.ancestor(
|
||||
of: inField('cat'),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final decoration =
|
||||
tester.widget<AnimatedContainer>(answer.first).decoration!
|
||||
as BoxDecoration;
|
||||
expect(decoration.color, Colors.transparent);
|
||||
});
|
||||
|
||||
testWidgets('leaving with a card that is incomplete asks first', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, 'ねこ');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Discard unsaved flashcard?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await settle(tester);
|
||||
expect(find.text('Discard unsaved flashcard?'), findsNothing);
|
||||
expect(gateway.written, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('a card changed differently on two devices shows every '
|
||||
'version; keeping one saves it on leaving, which settles it', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final created = (await cards.cardsOf('japanese')).single;
|
||||
await cards.save(created, card('猫', 'a cat'));
|
||||
gateway.receiveRemoteRevision(
|
||||
id: 'rev-9000',
|
||||
cardId: created.id,
|
||||
parents: created.heads,
|
||||
payload: encodeCardPayload(card('猫', 'the cat')),
|
||||
);
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
expect(
|
||||
find.textContaining('changed in two ways at once'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Keep this'), findsNWidgets(2));
|
||||
await tester.tap(find.text('Keep this').first);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
final resolved = (await cards.cardsOf('japanese')).single;
|
||||
expect(resolved.hasConflict, isFalse);
|
||||
expect(resolved.content.back, paragraph('a cat'));
|
||||
expect(find.text('Create flashcards for "Japanese"'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('deleting a stored card asks first, then blanks and removes '
|
||||
'it', (tester) async {
|
||||
final id = await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await action(tester, 'Delete');
|
||||
expect(find.text('Delete this flashcard?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await settle(tester);
|
||||
expect(gateway.deletions, isEmpty);
|
||||
|
||||
await action(tester, 'Delete');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await settle(tester);
|
||||
|
||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||
});
|
||||
|
||||
testWidgets('a character card takes its meanings and its on and kun '
|
||||
'readings, each kind on one line', (tester) async {
|
||||
await cards.create('japanese', card('食', 'food'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '食');
|
||||
|
||||
await tester.tap(find.text('Character'));
|
||||
await settle(tester);
|
||||
expect(find.text('On readings'), findsOneWidget);
|
||||
expect(find.text('Kun readings'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).at(0), 'eat');
|
||||
await tester.enterText(find.byType(TextField).at(1), 'ショク、ジキ');
|
||||
await tester.enterText(find.byType(TextField).at(2), 'く.う, た.べる');
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).single.content;
|
||||
expect(saved.type, CardType.character);
|
||||
expect(saved.front.plainText, '食');
|
||||
expect(saved.meanings, ['eat']);
|
||||
expect(saved.onReadings, ['ショク', 'ジキ']);
|
||||
expect(saved.kunReadings, ['く.う', 'た.べる']);
|
||||
expect(saved.back, RichContent.empty());
|
||||
});
|
||||
|
||||
testWidgets('a map question takes the region tapped on a screen of its '
|
||||
'own, where nothing scrolls, as its answer once saved', (tester) async {
|
||||
await cards.create('japanese', card('Where is Tokyo?', 'east'));
|
||||
await showStudyset(tester, maps: await loadedMaps(tester));
|
||||
await open(tester, 'Where is Tokyo?');
|
||||
|
||||
await tester.tap(find.text('Map'));
|
||||
await settle(tester);
|
||||
expect(find.byType(RegionMap), findsNothing);
|
||||
await pickOnMap(tester);
|
||||
expect(find.text('No region picked yet'), findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byType(RegionMap),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.widget<RegionMap>(find.byType(RegionMap)).wheelZooms, true);
|
||||
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||
await settle(tester);
|
||||
expect(find.text('Tokyo · 東京都'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||
await settle(tester);
|
||||
expect(find.byType(RegionMap), findsNothing);
|
||||
expect(find.text('Tokyo · 東京都'), findsOneWidget);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).single.content;
|
||||
expect(saved.type, CardType.multipleChoice);
|
||||
expect(saved.map, 'japan');
|
||||
expect(saved.answers.single.region, 'JP-13');
|
||||
expect(saved.answers.single.content.plainText, 'Tokyo');
|
||||
expect(saved.back, RichContent.empty());
|
||||
});
|
||||
|
||||
testWidgets("a map question on the world's countries names the world's "
|
||||
"map; a country is named in the studyset's language too", (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('Where is Cyprus?', 'east'));
|
||||
await showStudyset(
|
||||
tester,
|
||||
maps: await loadedMaps(tester),
|
||||
frontLanguage: 'ja',
|
||||
);
|
||||
await open(tester, 'Where is Cyprus?');
|
||||
|
||||
await tester.tap(find.text('Map'));
|
||||
await settle(tester);
|
||||
expect(find.text('Countries of the world'), findsNothing);
|
||||
await pickOnMap(tester);
|
||||
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||
await settle(tester);
|
||||
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('CY');
|
||||
await settle(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||
await settle(tester);
|
||||
// Beside the app's language, the studyset's: Japanese.
|
||||
expect(find.text('Cyprus · キプロス'), findsOneWidget);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).single.content;
|
||||
expect(saved.map, 'world');
|
||||
expect(saved.answers.single.region, 'CY');
|
||||
expect(saved.answers.single.content.plainText, 'Cyprus');
|
||||
});
|
||||
|
||||
testWidgets("a map question opens on the layer the studyset's map "
|
||||
'questions share: Japan, for Tokyo and Osaka', (tester) async {
|
||||
for (final (question, region, name) in [
|
||||
('Where is Tokyo?', 'JP-13', 'Tokyo'),
|
||||
('Where is Osaka?', 'JP-27', 'Ōsaka'),
|
||||
]) {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.multipleChoice,
|
||||
front: RichContent.plain(question),
|
||||
back: RichContent.empty(),
|
||||
answers: [
|
||||
CardAnswer(
|
||||
content: RichContent.plain(name),
|
||||
correct: true,
|
||||
region: region,
|
||||
),
|
||||
],
|
||||
map: 'japan',
|
||||
),
|
||||
);
|
||||
}
|
||||
await cards.create('japanese', card('Where is Kyoto?', 'west'));
|
||||
await showStudyset(tester, maps: await loadedMaps(tester));
|
||||
await open(tester, 'Where is Kyoto?');
|
||||
|
||||
await tester.tap(find.text('Map'));
|
||||
await settle(tester);
|
||||
await pickOnMap(tester);
|
||||
|
||||
expect(tester.widget<RegionMap>(find.byType(RegionMap)).around, {
|
||||
'JP-13',
|
||||
'JP-27',
|
||||
});
|
||||
expect(find.widgetWithText(TextButton, 'East Asia'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(ActionChip, 'Kansai', skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('leaving the map without saving keeps the answer', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('Where is Tokyo?', 'east'));
|
||||
await showStudyset(tester, maps: await loadedMaps(tester));
|
||||
await open(tester, 'Where is Tokyo?');
|
||||
await tester.tap(find.text('Map'));
|
||||
await settle(tester);
|
||||
|
||||
await pickOnMap(tester);
|
||||
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||
await settle(tester);
|
||||
await tester.tap(find.byType(CloseButton));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(RegionMap), findsNothing);
|
||||
expect(find.text('Tap to pick the region on the map'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/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_card_gateway.dart';
|
||||
import '../../../support/fake_file_picker.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cardGateway;
|
||||
late FakeFilePicker files;
|
||||
|
||||
setUp(() {
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'spanish',
|
||||
encodeStudysetPayload(StudysetContent.titled('Spanish vocab')),
|
||||
);
|
||||
cardGateway = FakeCardGateway();
|
||||
files = FakeFilePicker();
|
||||
});
|
||||
|
||||
Future<void> showApp(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cardGateway,
|
||||
files: files,
|
||||
),
|
||||
location: RoutePaths.studyset('spanish'),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens the studyset's Import content and chooses Flashcards.
|
||||
Future<void> openImport(WidgetTester tester) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Import content'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Flashcards').last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<List<(String, String)>> stored() async {
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
return [
|
||||
for (final card in await cards.cardsOf('spanish'))
|
||||
(
|
||||
card.content.front.plainText.trim(),
|
||||
card.content.back.plainText.trim(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
testWidgets('pasted lines become front/back cards at the end of the '
|
||||
'studyset, in order, marked as imported; the Flashcards tab shows '
|
||||
'them', (tester) async {
|
||||
await showApp(tester);
|
||||
await openImport(tester);
|
||||
expect(find.text('No cards found yet.'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Import'))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField),
|
||||
'hola\thello\nadiós\tgoodbye\nno separator here',
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('2 cards ready to import.'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Imported 2 flashcards.'), findsOneWidget);
|
||||
expect(await stored(), [('hola', 'hello'), ('adiós', 'goodbye')]);
|
||||
expect(
|
||||
[
|
||||
for (final revision in await cardGateway.allRevisions())
|
||||
cardOriginOf(revision.payload),
|
||||
],
|
||||
['text', 'text'],
|
||||
);
|
||||
expect(find.text('hola'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a CSV file fills the field and switches to commas', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
await openImport(tester);
|
||||
files.next = (
|
||||
name: 'words.csv',
|
||||
bytes: Uint8List.fromList(utf8.encode('"hello, friend",hola amigo\n')),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Load a CSV or TSV file'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(files.asked.single, ['csv', 'tsv', 'txt']);
|
||||
expect(find.text('1 card ready to import.'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await stored(), [('hello, friend', 'hola amigo')]);
|
||||
});
|
||||
|
||||
testWidgets('a file that holds no text says so', (tester) async {
|
||||
await showApp(tester);
|
||||
await openImport(tester);
|
||||
files.next = (
|
||||
name: 'words.csv',
|
||||
bytes: Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 0x00, 0x00, 0x1A]),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Load a CSV or TSV file'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text("This file doesn't hold text."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a file an older spreadsheet saved (Windows-1252, semicolons) '
|
||||
'is read as it is meant', (tester) async {
|
||||
await showApp(tester);
|
||||
await openImport(tester);
|
||||
// "Café;coffee" as Windows-1252.
|
||||
files.next = (
|
||||
name: 'words.csv',
|
||||
bytes: Uint8List.fromList([
|
||||
0x43,
|
||||
0x61,
|
||||
0x66,
|
||||
0xE9,
|
||||
0x3B,
|
||||
0x63,
|
||||
0x6F,
|
||||
0x66,
|
||||
0x66,
|
||||
0x65,
|
||||
0x65,
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Load a CSV or TSV file'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(TextField, 'Café;coffee'), findsOneWidget);
|
||||
expect(find.text('1 card ready to import.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
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/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.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' hide Card;
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
/// Rich content with one paragraph of [text].
|
||||
RichContent paragraph(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
CardContent card(String front, String back) =>
|
||||
CardContent(front: paragraph(front), back: paragraph(back));
|
||||
|
||||
void main() {
|
||||
late FakeCardGateway gateway;
|
||||
late CardRepository cards;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeCardGateway();
|
||||
cards = CardRepository(gateway);
|
||||
});
|
||||
tearDown(() => cards.dispose());
|
||||
|
||||
/// Opens the studyset `japanese`, with the cards the test gave [cards]: the
|
||||
/// app reads them from the same [gateway].
|
||||
Future<void> showList(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(studysetGateway: studysets, cardGateway: gateway),
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
);
|
||||
// Flushes the editor's timers.
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
/// A card whose back was changed differently on this device and another.
|
||||
Future<void> conflictedCard() async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final created = (await cards.cardsOf('japanese')).single;
|
||||
await cards.save(created, card('猫', 'a cat'));
|
||||
gateway.receiveRemoteRevision(
|
||||
id: 'rev-9000',
|
||||
cardId: created.id,
|
||||
parents: created.heads,
|
||||
payload: encodeCardPayload(card('猫', 'the cat')),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('says when a studyset has no cards yet', (tester) async {
|
||||
await showList(tester);
|
||||
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('lists the cards in order, each by its front', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await cards.create('japanese', card('犬', 'dog'));
|
||||
|
||||
await showList(tester);
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.text('猫')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('犬')).dy),
|
||||
);
|
||||
// The back stays for the composer and studying.
|
||||
expect(find.text('dog'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('marks a card changed on two devices', (tester) async {
|
||||
await conflictedCard();
|
||||
|
||||
await showList(tester);
|
||||
|
||||
expect(find.text('Changed on two devices'), findsOneWidget);
|
||||
});
|
||||
|
||||
/// Deletes the card shown through its options, saying yes.
|
||||
Future<void> delete(WidgetTester tester) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(CardTile),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
// A menu item acts once the menu has closed, a frame later.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete flashcard?'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("a card's options delete it, after asking; once the time to "
|
||||
'undo is over, for good', (tester) async {
|
||||
final id = await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
|
||||
await delete(tester);
|
||||
|
||||
expect(find.text('Flashcard deleted'), findsOneWidget);
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
expect(gateway.deletions, isEmpty);
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||
});
|
||||
|
||||
testWidgets('Undo keeps a card deleted a moment ago', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
|
||||
await delete(tester);
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('猫'), findsOneWidget);
|
||||
expect(gateway.deletions, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("a card's options report it and bury it, each shown on the "
|
||||
'card and taken back the same way', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
Future<void> choose(String item) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(CardTile),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(item));
|
||||
// A menu item acts once the menu has closed, a frame later.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await choose('Report an error');
|
||||
await tester.tap(find.text('Grammar'));
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Report'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Flagged for fixing'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CardTile),
|
||||
matching: find.text('Grammar'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await choose('Bury until tomorrow');
|
||||
expect(find.text('Buried'), findsOneWidget);
|
||||
|
||||
await choose('Unbury');
|
||||
expect(find.text('Buried'), findsNothing);
|
||||
await choose('Resolve report');
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CardTile),
|
||||
matching: find.text('Grammar'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("a card's More info shows its ids, to copy, and its reviews", (
|
||||
tester,
|
||||
) async {
|
||||
final id = await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(CardTile),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('More info'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(id), findsOneWidget);
|
||||
expect(find.text('japanese'), findsOneWidget);
|
||||
expect(find.text('Review count'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Copy').first);
|
||||
await tester.pump();
|
||||
expect(find.text('Copied to clipboard'), findsOneWidget);
|
||||
});
|
||||
|
||||
/// The "…" of the card whose front is [front].
|
||||
Finder optionsOf(String front) => find.descendant(
|
||||
of: find.widgetWithText(CardTile, front),
|
||||
matching: find.byTooltip('Options'),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
"on desktop, a card's whole … opens its options, with no handle to "
|
||||
'drag it over it: cards keep the order they came in',
|
||||
(tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await cards.create('japanese', card('犬', 'dog'));
|
||||
await showList(tester);
|
||||
|
||||
expect(find.byIcon(Icons.drag_handle), findsNothing);
|
||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
expect(find.text('Reorder'), findsNothing);
|
||||
await tester.tap(optionsOf('猫'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.linux),
|
||||
);
|
||||
|
||||
testWidgets('builds only the cards in view, so a studyset of a thousand '
|
||||
'opens as fast as one of ten', (tester) async {
|
||||
await cards.createAll('japanese', [
|
||||
for (var i = 0; i < 1000; i++) card('Card $i', 'Back $i'),
|
||||
]);
|
||||
await showList(tester);
|
||||
|
||||
expect(find.text('Card 0'), findsOneWidget);
|
||||
expect(find.text('Card 999', skipOffstage: false), findsNothing);
|
||||
expect(
|
||||
find.byType(CardTile, skipOffstage: false).evaluate().length,
|
||||
lessThan(50),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
// 家: "house; home", a noun, also a suffix; "one's house", a noun.
|
||||
const senses = [
|
||||
VocabularySense(glosses: ['house', 'home'], pos: ['n', 'n-suf']),
|
||||
VocabularySense(glosses: ["one's house"], pos: ['n']),
|
||||
];
|
||||
|
||||
Future<ThemeData> show(WidgetTester tester, Widget child) async {
|
||||
final theme = learnTheme(Brightness.light);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
localizationsDelegates: appLocalizationsDelegates,
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
return theme;
|
||||
}
|
||||
|
||||
testWidgets("each sense's grammar notes follow its glosses, small, grey "
|
||||
'and slanted, their meaning on hover; several numbered', (
|
||||
tester,
|
||||
) async {
|
||||
final theme = await show(
|
||||
tester,
|
||||
const VocabularySensesView(
|
||||
senses: senses,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('1.'), findsOneWidget);
|
||||
expect(find.text('house; home'), findsOneWidget);
|
||||
final tag = tester.widget<Text>(find.text('n,'));
|
||||
expect(tag.style?.fontStyle, FontStyle.italic);
|
||||
expect(tag.style?.fontSize, 16 * 0.85);
|
||||
expect(tag.style?.color, theme.colorScheme.onSurfaceVariant);
|
||||
expect(find.text('n-suf'), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Tooltip && widget.message == 'noun, used as a suffix',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("in a list's row, one sense to a line, as Jisho lists them: "
|
||||
'glosses, then the notes slanted and grey', (tester) async {
|
||||
final theme = await show(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) => Text.rich(
|
||||
vocabularySensesSpan(context, senses, style: const TextStyle()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final text = tester.widget<RichText>(find.byType(RichText).last).text;
|
||||
expect(
|
||||
text.toPlainText(),
|
||||
"1. house; home n, n-suf\n2. one's house n",
|
||||
);
|
||||
final notes = <TextSpan>[];
|
||||
text.visitChildren((span) {
|
||||
if (span is TextSpan && (span.text ?? '').contains('n-suf')) {
|
||||
notes.add(span);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
expect(notes.single.style?.fontStyle, FontStyle.italic);
|
||||
expect(notes.single.style?.color, theme.colorScheme.onSurfaceVariant);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue