Tapping a letter of the Greek alphabet among the dictionary's results opened the character sheet, which could only say that there's no stroke order for it. A letter of an alphabet has no strokes drawn, so the dictionary now offers the tap only for characters of a script whose strokes are drawn (Han characters, kana, Hangul), as a studied character's answer does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
600 lines
19 KiB
Dart
600 lines
19 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||
import 'package:halkyon_learn_flutter/src/core/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/kanji/data/stroke_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_dictionary_gateway.dart';
|
||
import '../../support/fake_studyset_gateway.dart';
|
||
import '../../support/loaded_strokes.dart';
|
||
import '../../support/pump_app.dart';
|
||
import '../../support/test_dependencies.dart';
|
||
|
||
final taberu = word(
|
||
'1358280',
|
||
'食べる',
|
||
reading: 'たべる',
|
||
glosses: ['to eat'],
|
||
labels: ['common', 'jlpt:n5'],
|
||
pos: ['v1'],
|
||
);
|
||
final terebi = word(
|
||
'1080510',
|
||
'テレビ',
|
||
glosses: ['television'],
|
||
labels: ['common', 'jlpt:n5'],
|
||
pos: ['n'],
|
||
);
|
||
final toukyou = word(
|
||
'1447690',
|
||
'東京',
|
||
reading: 'とうきょう',
|
||
glosses: ['Tokyo'],
|
||
labels: ['common'],
|
||
pos: ['n'],
|
||
);
|
||
|
||
final shoku = character(
|
||
'食',
|
||
meanings: ['eat', 'food'],
|
||
on: ['ショク'],
|
||
kun: ['た.べる'],
|
||
labels: ['jlpt:n5', 'grade:2'],
|
||
grade: 2,
|
||
strokes: 9,
|
||
);
|
||
final a = character('あ', labels: ['hiragana'], romaji: 'a');
|
||
|
||
/// An instance with JMdict and KANJIDIC2.
|
||
FakeDictionaryGateway withKanji() => FakeDictionaryGateway(
|
||
available: [jmdict, kanjidic],
|
||
words: [taberu, terebi, shoku, a],
|
||
);
|
||
|
||
void main() {
|
||
late FakeCardGateway cardGateway;
|
||
late CardRepository cards;
|
||
|
||
setUp(() {
|
||
cardGateway = FakeCardGateway();
|
||
cards = CardRepository(cardGateway);
|
||
});
|
||
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();
|
||
}
|
||
|
||
/// Opens the studyset [id]: `japanese` (Japanese with English meanings),
|
||
/// `german`, `greek` or `chinese`, on an instance with JMdict
|
||
/// ([dictionaries]), at
|
||
/// [location] (the studyset's screen unless given).
|
||
Future<void> show(
|
||
WidgetTester tester, {
|
||
String id = 'japanese',
|
||
FakeDictionaryGateway? dictionaries,
|
||
StrokeRepository? strokes,
|
||
String? location,
|
||
}) async {
|
||
tester.view.physicalSize = const Size(1400, 1600);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
final studysets = FakeStudysetGateway()
|
||
..receiveRemoteChange(
|
||
'japanese',
|
||
encodeStudysetPayload(
|
||
StudysetContent(
|
||
title: 'Japanese',
|
||
description: RichContent.empty(),
|
||
frontLanguage: 'ja',
|
||
backLanguage: 'en',
|
||
),
|
||
),
|
||
)
|
||
..receiveRemoteChange(
|
||
'german',
|
||
encodeStudysetPayload(
|
||
StudysetContent(
|
||
title: 'German',
|
||
description: RichContent.empty(),
|
||
frontLanguage: 'de',
|
||
backLanguage: 'en',
|
||
),
|
||
),
|
||
)
|
||
..receiveRemoteChange(
|
||
'greek',
|
||
encodeStudysetPayload(
|
||
StudysetContent(
|
||
title: 'Greek',
|
||
description: RichContent.empty(),
|
||
frontLanguage: 'el',
|
||
backLanguage: 'en',
|
||
),
|
||
),
|
||
)
|
||
..receiveRemoteChange(
|
||
'chinese',
|
||
encodeStudysetPayload(
|
||
StudysetContent(
|
||
title: 'Chinese',
|
||
description: RichContent.empty(),
|
||
frontLanguage: 'zh-Hans',
|
||
backLanguage: 'en',
|
||
),
|
||
),
|
||
);
|
||
await pumpApp(
|
||
tester,
|
||
testDependencies(
|
||
studysetGateway: studysets,
|
||
cardGateway: cardGateway,
|
||
dictionaryGateway:
|
||
dictionaries ??
|
||
FakeDictionaryGateway(words: [taberu, terebi, toukyou]),
|
||
strokes: strokes,
|
||
),
|
||
location: location ?? RoutePaths.studyset(id),
|
||
);
|
||
await settle(tester);
|
||
}
|
||
|
||
/// Chooses [item] under Add material.
|
||
Future<void> addMaterial(WidgetTester tester, String item) async {
|
||
await tester.tap(find.text('Add material'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text(item));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
await settle(tester);
|
||
}
|
||
|
||
testWidgets('Add material offers the dictionary only where the '
|
||
"studyset's language has one", (tester) async {
|
||
await show(tester, id: 'german');
|
||
await tester.tap(find.text('Add material'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('From the dictionary'), findsNothing);
|
||
});
|
||
|
||
testWidgets('a word found is added as a flashcard, and then shows as in '
|
||
'the studyset; the screen credits its sources', (tester) async {
|
||
await show(tester);
|
||
await addMaterial(tester, 'From the dictionary');
|
||
|
||
// Its sources by name in a line; who made them, under which licence,
|
||
// a tap away.
|
||
expect(find.textContaining('Words from JMdict'), findsOneWidget);
|
||
expect(find.textContaining('EDRDG'), findsNothing);
|
||
await tester.tap(find.byTooltip('Sources'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.textContaining('EDRDG'), findsOneWidget);
|
||
await tester.tap(find.byTooltip('Close'));
|
||
await tester.pumpAndSettle();
|
||
await tester.enterText(find.byType(TextField), 'たべ');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
expect(find.textContaining('食べる'), findsOneWidget);
|
||
await tester.tap(find.text('Add'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Added “食べる”.'), findsOneWidget);
|
||
expect(find.byTooltip('In this studyset'), findsOneWidget);
|
||
final card = (await cards.cardsOf('japanese')).single;
|
||
expect(card.content.entry, (source: 'jmdict', id: '1358280'));
|
||
expect(card.content.reading, 'たべる');
|
||
});
|
||
|
||
testWidgets('tapping Add twice makes one card', (tester) async {
|
||
await show(tester);
|
||
await addMaterial(tester, 'From the dictionary');
|
||
await tester.enterText(find.byType(TextField), 'たべ');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Add'));
|
||
await tester.tap(find.text('Add'), warnIfMissed: false);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(await cards.cardsOf('japanese'), hasLength(1));
|
||
});
|
||
|
||
testWidgets('without the instance, the dictionary says so and offers to '
|
||
'try again, which finds it once the instance answers', (tester) async {
|
||
final dictionaries = FakeDictionaryGateway(words: [taberu])
|
||
..fail = Exception('offline');
|
||
await show(
|
||
tester,
|
||
dictionaries: dictionaries,
|
||
location: RoutePaths.dictionary('japanese'),
|
||
);
|
||
|
||
expect(find.text("The dictionary can't be reached right now."), findsOne);
|
||
dictionaries.fail = null;
|
||
await tester.tap(find.text('Retry'));
|
||
await settle(tester);
|
||
|
||
expect(find.text('N5'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('a studyset whose language has no dictionary says so', (
|
||
tester,
|
||
) async {
|
||
await show(tester, id: 'german', location: RoutePaths.dictionary('german'));
|
||
|
||
expect(
|
||
find.text("This studyset's language has no dictionary on this instance."),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('a whole level becomes flashcards, its words in its order', (
|
||
tester,
|
||
) async {
|
||
await show(tester);
|
||
await addMaterial(tester, 'From the dictionary');
|
||
|
||
expect(find.text('N5'), findsOneWidget);
|
||
await tester.tap(find.text('Add 2 words'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Added 2 words.'), findsOneWidget);
|
||
expect(
|
||
[
|
||
for (final card in await cards.cardsOf('japanese'))
|
||
card.content.front.plainText.trim(),
|
||
],
|
||
['食べる', 'テレビ'],
|
||
);
|
||
});
|
||
|
||
testWidgets('beside the words, kanji and kana: their levels in groups, '
|
||
'one added as character cards tagged with level and grade', (
|
||
tester,
|
||
) async {
|
||
await show(tester, dictionaries: withKanji());
|
||
await addMaterial(tester, 'From the dictionary');
|
||
|
||
expect(find.text('Words'), findsOneWidget);
|
||
await tester.tap(find.text('Kanji & kana'));
|
||
await settle(tester);
|
||
expect(find.text('Kana'), findsOneWidget);
|
||
expect(find.text('JLPT levels'), findsOneWidget);
|
||
expect(find.text('School grades'), findsOneWidget);
|
||
expect(find.text('Grade 2'), findsOneWidget);
|
||
await tester.tap(find.text('N5').hitTestable());
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Add 1 character'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Added 1 character.'), findsOneWidget);
|
||
final made = (await cards.cardsOf('japanese')).single;
|
||
expect(made.content.type, CardType.character);
|
||
expect(made.content.onReadings, ['ショク']);
|
||
expect(made.content.entry, (source: 'kanjidic', id: '食'));
|
||
});
|
||
|
||
testWidgets("Greek's letters in a tab of their own, without radicals, "
|
||
'added whole as character cards', (tester) async {
|
||
await show(
|
||
tester,
|
||
id: 'greek',
|
||
dictionaries: FakeDictionaryGateway(
|
||
available: [wiktionaryEl, greekAlphabet],
|
||
words: [
|
||
character(
|
||
'Β β',
|
||
labels: ['greek'],
|
||
romaji: 'v',
|
||
name: 'βήτα (beta)',
|
||
of: greekAlphabet,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await addMaterial(tester, 'From the dictionary');
|
||
|
||
expect(find.text('Radicals'), findsNothing);
|
||
await tester.tap(find.text('Alphabet'));
|
||
await settle(tester);
|
||
expect(find.text('Letters'), findsOneWidget);
|
||
await tester.tap(find.text('Greek alphabet').hitTestable());
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Add 1 character'));
|
||
await tester.pumpAndSettle();
|
||
|
||
final made = (await cards.cardsOf('greek')).single;
|
||
expect(made.content.front.plainText.trim(), 'Β β');
|
||
expect(made.content.meanings, ['v', 'βήτα (beta)']);
|
||
});
|
||
|
||
testWidgets('Chinese characters by HSK level and in common use, beside '
|
||
'the radicals in their simplified forms', (tester) async {
|
||
await show(
|
||
tester,
|
||
id: 'chinese',
|
||
dictionaries: FakeDictionaryGateway(
|
||
available: [ccCedictHans, unihanHans],
|
||
words: [
|
||
character(
|
||
'说',
|
||
meanings: ['speak'],
|
||
on: ['shuō'],
|
||
labels: ['hsk:1', 'common', 'radical:149'],
|
||
of: unihanHans,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await addMaterial(tester, 'From the dictionary');
|
||
|
||
await tester.tap(find.text('Hanzi'));
|
||
await settle(tester);
|
||
expect(find.text('HSK levels'), findsOneWidget);
|
||
expect(find.text('In common use'), findsOneWidget);
|
||
expect(find.text('HSK 1'), findsOneWidget);
|
||
await tester.tap(find.text('Radicals'));
|
||
await settle(tester);
|
||
// 言 as simplified Chinese writes it at a character's side.
|
||
await tester.scrollUntilVisible(
|
||
find.text('讠'),
|
||
400,
|
||
scrollable: find.byType(Scrollable).last,
|
||
);
|
||
expect(find.text('讠'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('a character found shows its readings and strokes, and is '
|
||
'added as a card', (tester) async {
|
||
await show(tester, dictionaries: withKanji());
|
||
await addMaterial(tester, 'From the dictionary');
|
||
await tester.tap(find.text('Kanji & kana'));
|
||
await settle(tester);
|
||
|
||
await tester.enterText(find.byType(TextField).hitTestable(), 'ショク');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('ショク · た.べる'), findsOneWidget);
|
||
expect(find.text('9 strokes'), findsOneWidget);
|
||
await tester.tap(find.text('Add').hitTestable());
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Added “食”.'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('a character found shows how it is written when tapped', (
|
||
tester,
|
||
) async {
|
||
await show(
|
||
tester,
|
||
dictionaries: withKanji(),
|
||
strokes: await loadedStrokes(tester, ['食']),
|
||
);
|
||
await addMaterial(tester, 'From the dictionary');
|
||
await tester.tap(find.text('Kanji & kana'));
|
||
await settle(tester);
|
||
await tester.enterText(find.byType(TextField).hitTestable(), 'eat');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('eat; food'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Stroke order'), findsNothing);
|
||
expect(find.text('Play again'), findsOneWidget);
|
||
expect(find.text('eat, food'), findsOneWidget);
|
||
expect(find.text('N5 · Grade 2'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('a Greek letter found opens no sheet of its strokes: none are '
|
||
'drawn', (tester) async {
|
||
await show(
|
||
tester,
|
||
id: 'greek',
|
||
dictionaries: FakeDictionaryGateway(
|
||
available: [wiktionaryEl, greekAlphabet],
|
||
words: [
|
||
character(
|
||
'Α α',
|
||
labels: ['greek'],
|
||
romaji: 'a',
|
||
name: 'άλφα (alpha)',
|
||
of: greekAlphabet,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
await addMaterial(tester, 'From the dictionary');
|
||
await tester.tap(find.text('Alphabet'));
|
||
await settle(tester);
|
||
await tester.enterText(find.byType(TextField).hitTestable(), 'α');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Α α').hitTestable());
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.text("There's no stroke order for this character."),
|
||
findsNothing,
|
||
);
|
||
expect(find.text('Play again'), findsNothing);
|
||
});
|
||
|
||
testWidgets('Look up fills a character card with its meanings, readings '
|
||
'and tags', (tester) async {
|
||
await show(tester, dictionaries: withKanji());
|
||
await addMaterial(tester, 'Create flashcards');
|
||
await tester.tap(find.text('Character'));
|
||
await settle(tester);
|
||
|
||
await tester.tap(find.byTooltip('Look up in the dictionary'));
|
||
await tester.pumpAndSettle();
|
||
await tester.enterText(find.byType(TextField).last, 'eat');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('eat; food'));
|
||
await settle(tester);
|
||
|
||
expect(find.widgetWithText(TextField, 'eat'), findsOneWidget);
|
||
expect(find.widgetWithText(TextField, 'ショク'), findsOneWidget);
|
||
expect(find.widgetWithText(TextField, 'た.べる'), findsOneWidget);
|
||
expect(find.text('JLPT-N5'), findsOneWidget);
|
||
expect(find.text('Grade 2'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('Look up fills a vocabulary card with a word of the '
|
||
'dictionary', (tester) async {
|
||
await show(tester);
|
||
await addMaterial(tester, 'Create flashcards');
|
||
await tester.tap(find.text('Vocabulary'));
|
||
await settle(tester);
|
||
|
||
await tester.tap(find.byTooltip('Look up in the dictionary'));
|
||
await tester.pumpAndSettle();
|
||
await tester.enterText(find.byType(TextField).last, 'たべる');
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.textContaining('食べる').last);
|
||
await settle(tester);
|
||
|
||
expect(find.text('食べる', findRichText: true), findsWidgets);
|
||
expect(find.widgetWithText(TextField, 'たべる'), findsOneWidget);
|
||
expect(find.text('to eat'), findsWidgets);
|
||
// Tagged as words from the dictionary screen are.
|
||
expect(find.text('JLPT-N5'), findsOneWidget);
|
||
expect(find.text('Verb'), findsOneWidget);
|
||
});
|
||
|
||
group('Radicals', () {
|
||
final hito = character(
|
||
'人',
|
||
meanings: ['person'],
|
||
labels: ['jlpt:n5', 'grade:1', 'radical:9'],
|
||
);
|
||
final yasumu = character(
|
||
'休',
|
||
meanings: ['rest'],
|
||
labels: ['jlpt:n5', 'grade:1', 'radical:9'],
|
||
);
|
||
|
||
/// An instance whose KANJIDIC2 files 人 and 休 under the radical 人.
|
||
FakeDictionaryGateway withRadicals() => FakeDictionaryGateway(
|
||
available: [jmdict, kanjidic],
|
||
words: [taberu, hito, yasumu, shoku],
|
||
);
|
||
|
||
Future<void> openRadicals(
|
||
WidgetTester tester,
|
||
FakeDictionaryGateway dictionaries,
|
||
) async {
|
||
await show(
|
||
tester,
|
||
dictionaries: dictionaries,
|
||
location: RoutePaths.dictionary('japanese'),
|
||
);
|
||
await tester.tap(find.text('Radicals'));
|
||
await settle(tester);
|
||
}
|
||
|
||
testWidgets('the radicals by their strokes; one lists the kanji written '
|
||
'with it, to add, and leads back to them all', (tester) async {
|
||
await openRadicals(tester, withRadicals());
|
||
|
||
expect(find.text('1 stroke'), findsOneWidget);
|
||
expect(find.text('2 strokes'), findsOneWidget);
|
||
await tester.tap(find.text('9'));
|
||
await settle(tester);
|
||
|
||
expect(find.text('man'), findsOneWidget);
|
||
expect(find.text('Radical 9 · 2 strokes · 2 kanji'), findsOneWidget);
|
||
expect(find.text('食'), findsNothing);
|
||
await tester.tap(find.text('Add').first);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Added “人”.'), findsOneWidget);
|
||
expect(
|
||
(await cards.cardsOf('japanese')).single.content.entry,
|
||
(source: 'kanjidic', id: '人'),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('All radicals'));
|
||
await settle(tester);
|
||
expect(find.text('2 strokes'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets("a radical's kanji the instance can't give now are asked "
|
||
'for again', (tester) async {
|
||
final dictionaries = withRadicals();
|
||
await openRadicals(tester, dictionaries);
|
||
dictionaries.fail = Exception('offline');
|
||
await tester.tap(find.text('9'));
|
||
await settle(tester);
|
||
|
||
expect(
|
||
find.text("The dictionary can't be reached right now."),
|
||
findsOneWidget,
|
||
);
|
||
dictionaries.fail = null;
|
||
await tester.tap(find.text('Retry'));
|
||
await settle(tester);
|
||
expect(find.text('Radical 9 · 2 strokes · 2 kanji'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('a radical KANJIDIC2 files nothing under on this instance '
|
||
'says so', (tester) async {
|
||
await openRadicals(tester, withKanji());
|
||
await tester.tap(find.text('9'));
|
||
await settle(tester);
|
||
|
||
expect(
|
||
find.text(
|
||
"The instance's dictionary has no kanji under this radical "
|
||
'yet.',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
});
|
||
|
||
testWidgets("About names whose work the instance's dictionaries and the "
|
||
"app's own data are", (
|
||
tester,
|
||
) async {
|
||
await pumpApp(
|
||
tester,
|
||
testDependencies(dictionaryGateway: FakeDictionaryGateway()),
|
||
location: RoutePaths.settings,
|
||
);
|
||
await tester.scrollUntilVisible(
|
||
find.text('About Halkyon Learn'),
|
||
200,
|
||
scrollable: find.byType(Scrollable).first,
|
||
);
|
||
// Wholly in view: scrolling stops once any of it shows.
|
||
await tester.ensureVisible(find.text('About Halkyon Learn'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('About Halkyon Learn'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Dictionaries on this instance'), findsOneWidget);
|
||
expect(
|
||
find.text(
|
||
'JMdict: Electronic Dictionary Research and Development Group '
|
||
'(EDRDG), CC-BY-SA-4.0',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
// The app's own data, KanjiVG's strokes, as its licence asks.
|
||
expect(find.text('Data in the app'), findsOneWidget);
|
||
expect(find.text('KanjiVG: Ulrich Apel, CC-BY-SA-3.0'), findsOneWidget);
|
||
expect(find.text('https://kanjivg.tagaini.net'), findsOneWidget);
|
||
});
|
||
}
|