chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 0 additions and 0 deletions

View file

@ -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);
});
}

View file

@ -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);
});
}

View file

@ -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);
});
}

View file

@ -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),
);
});
}

View file

@ -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);
});
}