import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:muzzle_velocity/markdown_editing.dart'; import 'package:muzzle_velocity/markdown_view.dart'; import 'package:muzzle_velocity/note_editor.dart'; import 'package:muzzle_velocity/themes/day.dart'; import 'package:muzzle_velocity/themes/night.dart'; import 'package:shared_preferences/shared_preferences.dart'; Future _press(WidgetTester tester, LogicalKeyboardKey key, {bool control = false, bool shift = false}) async { if (control) await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); await tester.sendKeyEvent(key); if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); if (control) await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pumpAndSettle(); } void main() { late File note; setUp(() { SharedPreferences.setMockInitialValues({}); note = File('${Directory.systemTemp.createTempSync('mv').path}/n.md') ..writeAsStringSync('one word\ntwo word\n'); }); Future pumpEditor(WidgetTester tester, {Future Function()? onQuit}) async { await tester.runAsync(() async { await tester.pumpWidget(MaterialApp( home: NoteEditorScreen( note: note, notesDirectoryPath: note.parent.path, searchTerms: const ['word'], onQuit: onQuit ?? () async {}, ), )); // File reads complete outside the fake clock. await Future.delayed(const Duration(milliseconds: 100)); }); await tester.pumpAndSettle(); return tester.widget(find.byType(TextField)); } TextField field(WidgetTester tester) => tester.widget(find.byType(TextField)); testWidgets('Ctrl+E toggles editing and Esc leaves it', (tester) async { expect((await pumpEditor(tester)).readOnly, isTrue); await _press(tester, LogicalKeyboardKey.keyE, control: true); expect(field(tester).readOnly, isFalse); await _press(tester, LogicalKeyboardKey.escape); expect(field(tester).readOnly, isTrue); }); testWidgets('Ctrl+B and Ctrl+I wrap the selection in edit mode', (tester) async { await pumpEditor(tester); await _press(tester, LogicalKeyboardKey.keyE, control: true); final controller = field(tester).controller!; controller.selection = const TextSelection(baseOffset: 0, extentOffset: 3); await _press(tester, LogicalKeyboardKey.keyB, control: true); expect(controller.text, startsWith('**one** word')); await _press(tester, LogicalKeyboardKey.keyI, control: true); expect(controller.text, startsWith('***one*** word')); }); testWidgets('F3 and Shift+F3 cycle search matches', (tester) async { await pumpEditor(tester); final controller = field(tester).controller! as MarkdownController; expect(controller.currentMatch, 0); await _press(tester, LogicalKeyboardKey.f3); expect(controller.currentMatch, 1); await _press(tester, LogicalKeyboardKey.f3); expect(controller.currentMatch, 0); await _press(tester, LogicalKeyboardKey.f3, shift: true); expect(controller.currentMatch, 1); }); testWidgets('arrows scroll in read and preview mode, not in edit mode', (tester) async { note.writeAsStringSync(List.generate(200, (i) => 'line $i').join('\n')); await pumpEditor(tester); ScrollController sourceScroll() => field(tester).scrollController!; await _press(tester, LogicalKeyboardKey.arrowDown); expect(sourceScroll().offset, greaterThan(0)); await _press(tester, LogicalKeyboardKey.arrowUp); expect(sourceScroll().offset, 0); await _press(tester, LogicalKeyboardKey.arrowDown); final readOffset = sourceScroll().offset; await _press(tester, LogicalKeyboardKey.keyE, control: true); expect(sourceScroll().offset, readOffset, reason: 'entering edit mode keeps the scroll position'); await _press(tester, LogicalKeyboardKey.arrowDown); expect(sourceScroll().offset, readOffset, reason: 'arrows move the caret in edit mode'); await _press(tester, LogicalKeyboardKey.keyP, control: true); final preview = tester .widget(find.byType(SingleChildScrollView)) .controller!; await _press(tester, LogicalKeyboardKey.arrowDown); expect(preview.offset, greaterThan(0)); }); testWidgets('Page Down and Page Up scroll by a screen in read mode', (tester) async { note.writeAsStringSync(List.generate(400, (i) => 'line $i').join('\n')); await pumpEditor(tester); final scroll = field(tester).scrollController!; await _press(tester, LogicalKeyboardKey.pageDown); final page = scroll.offset; expect(page, closeTo(scroll.position.viewportDimension * 0.9, 1)); await _press(tester, LogicalKeyboardKey.pageUp); expect(scroll.offset, 0); }); testWidgets('Ctrl+Q quits, asking first about unsaved changes', (tester) async { var quits = 0; await pumpEditor(tester, onQuit: () async => quits++); await _press(tester, LogicalKeyboardKey.keyQ, control: true); expect(quits, 1); field(tester).controller!.text = 'changed'; await _press(tester, LogicalKeyboardKey.keyQ, control: true); expect(find.text('Save Changes?'), findsOneWidget); expect(quits, 1); await tester.tap(find.text('Discard')); await tester.pumpAndSettle(); expect(quits, 2); }); testWidgets('Ctrl+L goes back to the list', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(MaterialApp( home: Builder( builder: (context) => TextButton( onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (_) => NoteEditorScreen( note: note, notesDirectoryPath: note.parent.path), ), ), child: const Text('list'), ), ), )); await tester.tap(find.text('list')); await tester.pump(); await Future.delayed(const Duration(milliseconds: 100)); }); await tester.pumpAndSettle(); expect(find.byType(NoteEditorScreen), findsOneWidget); await _press(tester, LogicalKeyboardKey.keyL, control: true); expect(find.byType(NoteEditorScreen), findsNothing); }); testWidgets('holding the D-pad center goes back to the list', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(MaterialApp( home: Builder( builder: (context) => TextButton( onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (_) => NoteEditorScreen( note: note, notesDirectoryPath: note.parent.path), ), ), child: const Text('list'), ), ), )); await tester.tap(find.text('list')); await tester.pump(); await Future.delayed(const Duration(milliseconds: 100)); }); await tester.pumpAndSettle(); await tester.sendKeyDownEvent(LogicalKeyboardKey.select); await tester.pump(const Duration(milliseconds: 200)); await tester.sendKeyUpEvent(LogicalKeyboardKey.select); await tester.pumpAndSettle(); expect(find.byType(NoteEditorScreen), findsOneWidget, reason: 'a short press stays in the note'); await tester.sendKeyDownEvent(LogicalKeyboardKey.select); await tester.pump(const Duration(milliseconds: 600)); await tester.sendKeyUpEvent(LogicalKeyboardKey.select); await tester.pumpAndSettle(); expect(find.byType(NoteEditorScreen), findsNothing); }); testWidgets('D-pad Right opens the menu and Left goes back to the list', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(MaterialApp( home: Builder( builder: (context) => TextButton( onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (_) => NoteEditorScreen( note: note, notesDirectoryPath: note.parent.path), ), ), child: const Text('list'), ), ), )); await tester.tap(find.text('list')); await tester.pump(); await Future.delayed(const Duration(milliseconds: 100)); }); await tester.pumpAndSettle(); await _press(tester, LogicalKeyboardKey.arrowRight); expect(find.text('Save'), findsOneWidget); // Share has focus first; Down moves to Save, which closes the menu. await _press(tester, LogicalKeyboardKey.arrowDown); await _press(tester, LogicalKeyboardKey.select); expect(find.text('Save'), findsNothing); await _press(tester, LogicalKeyboardKey.keyE, control: true); await _press(tester, LogicalKeyboardKey.arrowRight); expect(find.text('Save'), findsNothing, reason: 'arrows move the caret in edit mode'); await _press(tester, LogicalKeyboardKey.arrowLeft); expect(find.byType(NoteEditorScreen), findsOneWidget); await _press(tester, LogicalKeyboardKey.keyE, control: true); await _press(tester, LogicalKeyboardKey.arrowLeft); expect(find.byType(NoteEditorScreen), findsNothing); }); testWidgets('the D-pad moves past the font size slider in the menu', (tester) async { await pumpEditor(tester); await _press(tester, LogicalKeyboardKey.arrowRight); String focusedLabel() { final context = FocusManager.instance.primaryFocus!.context!; if (context.findAncestorWidgetOfExactType() != null || context.widget is Slider) { return 'slider'; } final texts = find.descendant( of: find.byWidget(context.widget), matching: find.byType(Text)); return tester.widgetList(texts).map((t) => t.data).join(); } // Share, Save, Autosave, slider, Preview. for (var i = 0; i < 3; i++) { await _press(tester, LogicalKeyboardKey.arrowDown); } expect(focusedLabel(), 'slider'); await _press(tester, LogicalKeyboardKey.arrowRight); expect(tester.widget(find.byType(MarkdownView)).fontSize, 18, reason: 'Right adjusts the slider'); await _press(tester, LogicalKeyboardKey.arrowDown); expect(focusedLabel(), 'Preview Mode'); }); testWidgets('the open note follows theme switches', (tester) async { final mode = ValueNotifier(ThemeMode.light); await tester.runAsync(() async { await tester.pumpWidget(ValueListenableBuilder( valueListenable: mode, builder: (context, themeMode, _) => MaterialApp( theme: ThemeData(brightness: Brightness.light), darkTheme: ThemeData(brightness: Brightness.dark), themeMode: themeMode, home: NoteEditorScreen(note: note, notesDirectoryPath: null), ), )); await Future.delayed(const Duration(milliseconds: 100)); }); await tester.pumpAndSettle(); final controller = field(tester).controller! as MarkdownController; expect(controller.theme, same(dayTheme)); mode.value = ThemeMode.dark; await tester.pumpAndSettle(); expect(controller.theme, same(nightTheme)); }); testWidgets('Ctrl+= and Ctrl+- change the font size', (tester) async { await pumpEditor(tester); double size() => tester.widget(find.byType(MarkdownView)).fontSize; await _press(tester, LogicalKeyboardKey.equal, control: true); expect(size(), 18); await _press(tester, LogicalKeyboardKey.minus, control: true); await _press(tester, LogicalKeyboardKey.minus, control: true); expect(size(), 14); await _press(tester, LogicalKeyboardKey.digit0, control: true); expect(size(), 16); }); }