halkyon/halkyon_learn_flutter/test/feature/dictionary/dictionary_screen_test.dart
Tizian 「ティツィアーン」 e1032caf7e
fix(dictionary): a Greek letter found opens no empty sheet of strokes
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>
2026-10-14 18:23:53 +02:00

600 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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