halkyon/halkyon_learn_flutter/test/feature/dictionary/dictionary_screen_test.dart
Tizian 「ティツィアーン」 5479b973fc
feat(dictionary): characters of Chinese, Korean and Greek, written by hand; pinyin typed
Parity with Japanese's kanji and kana:

- The characters' tab is named by its language (Hanzi, Hangul & Hanja,
  Alphabet), its groups too: HSK levels and the characters in common use
  (Hong Kong's grades for traditional ones); Hangul and the Hanja taught
  in schools; Greek's letters. Radicals for Chinese and Korean in their
  own forms (讠 for 言 in simplified Chinese), none for Greek.
- A character's readings are named as its language names them: On and
  Kun, Pinyin, Eum and Hun (음, 훈); a letter means its sound and name.
- How characters are written comes by language: Chinese from Make Me a
  Hanzi (its medians in KanjiVG's square, Arphic Public License, a data
  file beside the app, never in cards), the Hangul letters drawn by the
  curator; a Korean word is written letter by letter (닭: ㄷ ㅏ ㄺ). Words
  of alphabets (German, Greek) aren't written by hand.
- A Chinese word's pinyin is typed in a session, tone numbers turning
  into marks (ni3 → nǐ); KRDict's levels (초급, 중급, 고급) tag Korean
  words; a Korean word's pronunciation shows in brackets ([가치]).
- Smaller: Look up in the composer keeps a noun's article; the Watch
  filter names its level list; hints and placeholders by language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 14:35:34 +02:00

565 lines
18 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('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);
});
}