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