feat: customize command shortcuts (#2848)

This commit is contained in:
Mayur Mahajan
2023-07-20 13:41:00 +05:30
committed by GitHub
parent 5ab64f8835
commit b1378b4545
15 changed files with 1253 additions and 1 deletions

View File

@ -0,0 +1,164 @@
import 'dart:ffi';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
// ignore: depend_on_referenced_packages
import 'package:mocktail/mocktail.dart';
class MockSettingsShortcutService extends Mock
implements SettingsShortcutService {}
void main() {
group("ShortcutsCubit", () {
late SettingsShortcutService service;
late ShortcutsCubit shortcutsCubit;
setUp(() async {
service = MockSettingsShortcutService();
when(
() => service.saveAllShortcuts(any()),
).thenAnswer((_) async => true);
when(
() => service.getCustomizeShortcuts(),
).thenAnswer((_) async => []);
when(
() => service.updateCommandShortcuts(any(), any()),
).thenAnswer((_) async => Void);
shortcutsCubit = ShortcutsCubit(service);
});
test('initial state is correct', () {
final shortcutsCubit = ShortcutsCubit(service);
expect(shortcutsCubit.state, const ShortcutsState());
});
group('fetchShortcuts', () {
blocTest<ShortcutsCubit, ShortcutsState>(
'calls getCustomizeShortcuts() once',
build: () => shortcutsCubit,
act: (cubit) => cubit.fetchShortcuts(),
verify: (_) {
verify(() => service.getCustomizeShortcuts()).called(1);
},
);
blocTest<ShortcutsCubit, ShortcutsState>(
'emits [updating, failure] when getCustomizeShortcuts() throws',
setUp: () {
when(
() => service.getCustomizeShortcuts(),
).thenThrow(Exception('oops'));
},
build: () => shortcutsCubit,
act: (cubit) => cubit.fetchShortcuts(),
expect: () => <dynamic>[
const ShortcutsState(status: ShortcutsStatus.updating),
isA<ShortcutsState>()
.having((w) => w.status, 'status', ShortcutsStatus.failure)
],
);
blocTest<ShortcutsCubit, ShortcutsState>(
'emits [updating, success] when getCustomizeShortcuts() returns shortcuts',
build: () => shortcutsCubit,
act: (cubit) => cubit.fetchShortcuts(),
expect: () => <dynamic>[
const ShortcutsState(status: ShortcutsStatus.updating),
isA<ShortcutsState>()
.having((w) => w.status, 'status', ShortcutsStatus.success)
.having(
(w) => w.commandShortcutEvents,
'shortcuts',
commandShortcutEvents,
),
],
);
});
group('updateShortcut', () {
blocTest<ShortcutsCubit, ShortcutsState>(
'calls saveAllShortcuts() once',
build: () => shortcutsCubit,
act: (cubit) => cubit.updateAllShortcuts(),
verify: (_) {
verify(() => service.saveAllShortcuts(any())).called(1);
},
);
blocTest<ShortcutsCubit, ShortcutsState>(
'emits [updating, failure] when saveAllShortcuts() throws',
setUp: () {
when(
() => service.saveAllShortcuts(any()),
).thenThrow(Exception('oops'));
},
build: () => shortcutsCubit,
act: (cubit) => cubit.updateAllShortcuts(),
expect: () => <dynamic>[
const ShortcutsState(status: ShortcutsStatus.updating),
isA<ShortcutsState>()
.having((w) => w.status, 'status', ShortcutsStatus.failure)
],
);
blocTest<ShortcutsCubit, ShortcutsState>(
'emits [updating, success] when saveAllShortcuts() is successful',
build: () => shortcutsCubit,
act: (cubit) => cubit.updateAllShortcuts(),
expect: () => <dynamic>[
const ShortcutsState(status: ShortcutsStatus.updating),
isA<ShortcutsState>()
.having((w) => w.status, 'status', ShortcutsStatus.success)
],
);
});
group('resetToDefault', () {
blocTest<ShortcutsCubit, ShortcutsState>(
'calls saveAllShortcuts() once',
build: () => shortcutsCubit,
act: (cubit) => cubit.resetToDefault(),
verify: (_) {
verify(() => service.saveAllShortcuts(any())).called(1);
verify(() => service.getCustomizeShortcuts()).called(1);
},
);
blocTest<ShortcutsCubit, ShortcutsState>(
'emits [updating, failure] when saveAllShortcuts() throws',
setUp: () {
when(
() => service.saveAllShortcuts(any()),
).thenThrow(Exception('oops'));
},
build: () => shortcutsCubit,
act: (cubit) => cubit.resetToDefault(),
expect: () => <dynamic>[
const ShortcutsState(status: ShortcutsStatus.updating),
isA<ShortcutsState>()
.having((w) => w.status, 'status', ShortcutsStatus.failure)
],
);
blocTest<ShortcutsCubit, ShortcutsState>(
'emits [updating, success] when getCustomizeShortcuts() returns shortcuts',
build: () => shortcutsCubit,
act: (cubit) => cubit.resetToDefault(),
expect: () => <dynamic>[
const ShortcutsState(status: ShortcutsStatus.updating),
isA<ShortcutsState>()
.having((w) => w.status, 'status', ShortcutsStatus.success)
.having(
(w) => w.commandShortcutEvents,
'shortcuts',
commandShortcutEvents,
),
],
);
});
});
}

View File

@ -0,0 +1,169 @@
import 'dart:convert';
import 'dart:io' show File;
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/shortcuts_model.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
// ignore: depend_on_referenced_packages
import 'package:file/memory.dart';
void main() {
late SettingsShortcutService service;
late File mockFile;
String shortcutsJson = '';
setUp(() async {
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
mockFile = await fileSystem.file("shortcuts.json").create(recursive: true);
service = SettingsShortcutService(file: mockFile);
shortcutsJson = """{
"commandShortcuts":[
{
"key":"move the cursor upward",
"command":"alt+arrow up"
},
{
"key":"move the cursor forward one character",
"command":"alt+arrow left"
},
{
"key":"move the cursor downward",
"command":"alt+arrow down"
}
]
}""";
});
group("Settings Shortcut Service", () {
test(
"returns default standard shortcuts if file is empty",
() async {
expect(await service.getCustomizeShortcuts(), []);
},
);
test('returns updated shortcut event list from json', () {
final commandShortcuts = service.getShortcutsFromJson(shortcutsJson);
final cursorUpShortcut = commandShortcuts
.firstWhere((el) => el.key == "move the cursor upward");
final cursorDownShortcut = commandShortcuts
.firstWhere((el) => el.key == "move the cursor downward");
expect(
commandShortcuts.length,
3,
);
expect(cursorUpShortcut.command, "alt+arrow up");
expect(cursorDownShortcut.command, "alt+arrow down");
});
test(
"saveAllShortcuts saves shortcuts",
() async {
//updating one of standard command shortcut events.
final currentCommandShortcuts = standardCommandShortcutEvents;
const kKey = "scroll one page down";
const oldCommand = "page down";
const newCommand = "alt+page down";
final commandShortcutEvent = currentCommandShortcuts
.firstWhere((element) => element.key == kKey);
expect(commandShortcutEvent.command, oldCommand);
//updating the command.
commandShortcutEvent.updateCommand(
command: newCommand,
);
//saving the updated shortcuts
await service.saveAllShortcuts(currentCommandShortcuts);
//reading from the mock file the saved shortcut list.
final savedDataInFile = await mockFile.readAsString();
//Check if the lists where properly converted to JSON and saved.
final shortcuts = EditorShortcuts(
commandShortcuts:
currentCommandShortcuts.toCommandShortcutModelList(),
);
expect(jsonEncode(shortcuts.toJson()), savedDataInFile);
//now checking if the modified command of "move the cursor upward" is "arrow up"
final newCommandShortcuts =
service.getShortcutsFromJson(savedDataInFile);
final updatedCommandEvent =
newCommandShortcuts.firstWhere((el) => el.key == kKey);
expect(updatedCommandEvent.command, newCommand);
},
);
test('load shortcuts from file', () async {
//updating one of standard command shortcut event.
const kKey = "scroll one page up";
const oldCommand = "page up";
const newCommand = "alt+page up";
final currentCommandShortcuts = standardCommandShortcutEvents;
final commandShortcutEvent =
currentCommandShortcuts.firstWhere((element) => element.key == kKey);
expect(commandShortcutEvent.command, oldCommand);
//updating the command.
commandShortcutEvent.updateCommand(command: newCommand);
//saving the updated shortcuts
service.saveAllShortcuts(currentCommandShortcuts);
//now directly fetching the shortcuts from loadShortcuts
final commandShortcuts = await service.getCustomizeShortcuts();
expect(
commandShortcuts,
currentCommandShortcuts.toCommandShortcutModelList(),
);
final updatedCommandEvent =
commandShortcuts.firstWhere((el) => el.key == kKey);
expect(updatedCommandEvent.command, newCommand);
});
test('updateCommandShortcuts works properly', () async {
//updating one of standard command shortcut event.
const kKey = "move the cursor forward one character";
const oldCommand = "arrow left";
const newCommand = "alt+arrow left";
final currentCommandShortcuts = standardCommandShortcutEvents;
//check if the current shortcut event's key is set to old command.
final currentCommandEvent =
currentCommandShortcuts.firstWhere((el) => el.key == kKey);
expect(currentCommandEvent.command, oldCommand);
final commandShortcutModelList =
EditorShortcuts.fromJson(jsonDecode(shortcutsJson)).commandShortcuts;
//now calling the updateCommandShortcuts method
await service.updateCommandShortcuts(
currentCommandShortcuts,
commandShortcutModelList,
);
//check if the shortcut event's key is updated.
final updatedCommandEvent =
currentCommandShortcuts.firstWhere((el) => el.key == kKey);
expect(updatedCommandEvent.command, newCommand);
});
});
}
extension on List<CommandShortcutEvent> {
List<CommandShortcutModel> toCommandShortcutModelList() =>
map((e) => CommandShortcutModel.fromCommandEvent(e)).toList();
}

View File

@ -0,0 +1,135 @@
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
// ignore: depend_on_referenced_packages
import 'package:mocktail/mocktail.dart';
class MockShortcutsCubit extends MockCubit<ShortcutsState>
implements ShortcutsCubit {}
void main() {
group(
"CustomizeShortcutsView",
() {
group(
"should be displayed in ViewState",
() {
late ShortcutsCubit mockShortcutsCubit;
setUp(() {
mockShortcutsCubit = MockShortcutsCubit();
});
testWidgets('Initial when cubit emits [ShortcutsStatus.Initial]',
(widgetTester) async {
when(() => mockShortcutsCubit.state)
.thenReturn(const ShortcutsState());
await widgetTester.pumpWidget(
BlocProvider.value(
value: mockShortcutsCubit,
child:
const MaterialApp(home: SettingsCustomizeShortcutsView()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets(
'Updating when cubit emits [ShortcutsStatus.updating]',
(widgetTester) async {
when(() => mockShortcutsCubit.state).thenReturn(
const ShortcutsState(status: ShortcutsStatus.updating),
);
await widgetTester.pumpWidget(
BlocProvider.value(
value: mockShortcutsCubit,
child:
const MaterialApp(home: SettingsCustomizeShortcutsView()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
},
);
testWidgets(
'Shows ShortcutsList when cubit emits [ShortcutsStatus.success]',
(widgetTester) async {
KeyEventResult dummyHandler(EditorState e) =>
KeyEventResult.handled;
final dummyShortcuts = <CommandShortcutEvent>[
CommandShortcutEvent(
key: 'Copy',
command: 'ctrl+c',
handler: dummyHandler,
),
CommandShortcutEvent(
key: 'Paste',
command: 'ctrl+v',
handler: dummyHandler,
),
CommandShortcutEvent(
key: 'Undo',
command: 'ctrl+z',
handler: dummyHandler,
),
CommandShortcutEvent(
key: 'Redo',
command: 'ctrl+y',
handler: dummyHandler,
),
];
when(() => mockShortcutsCubit.state).thenReturn(
ShortcutsState(
status: ShortcutsStatus.success,
commandShortcutEvents: dummyShortcuts,
),
);
await widgetTester.pumpWidget(
BlocProvider.value(
value: mockShortcutsCubit,
child:
const MaterialApp(home: SettingsCustomizeShortcutsView()),
),
);
await widgetTester.pump();
final listViewFinder = find.byType(ShortcutsListView);
final foundShortcuts = widgetTester
.widget<ShortcutsListView>(listViewFinder)
.shortcuts;
expect(listViewFinder, findsOneWidget);
expect(foundShortcuts, dummyShortcuts);
},
);
testWidgets('Shows Error when cubit emits [ShortcutsStatus.failure]',
(tester) async {
when(() => mockShortcutsCubit.state).thenReturn(
const ShortcutsState(
status: ShortcutsStatus.failure,
),
);
await tester.pumpWidget(
BlocProvider.value(
value: mockShortcutsCubit,
child:
const MaterialApp(home: SettingsCustomizeShortcutsView()),
),
);
expect(find.byType(ShortcutsErrorView), findsOneWidget);
});
},
);
},
);
}

View File

@ -0,0 +1,21 @@
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group("ShortcutsErrorView", () {
testWidgets("displays correctly", (widgetTester) async {
await widgetTester.pumpWidget(
const MaterialApp(
home: ShortcutsErrorView(
errorMessage: 'Error occured',
),
),
);
expect(find.byType(FlowyText), findsOneWidget);
expect(find.byType(FlowyIconButton), findsOneWidget);
});
});
}

View File

@ -0,0 +1,94 @@
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
KeyEventResult dummyHandler(EditorState e) => KeyEventResult.handled;
final shortcut = CommandShortcutEvent(
key: 'Copy',
command: 'ctrl+c',
handler: dummyHandler,
);
group("ShortcutsListTile", () {
group(
"should be displayed correctly",
() {
testWidgets('with key and command', (widgetTester) async {
final sKey = Key(shortcut.key);
await widgetTester.pumpWidget(
MaterialApp(
home: ShortcutsListTile(shortcutEvent: shortcut),
),
);
final commandTextFinder = find.byKey(sKey);
final foundCommand =
widgetTester.widget<FlowyText>(commandTextFinder).text;
expect(commandTextFinder, findsOneWidget);
expect(foundCommand, shortcut.key);
final btnFinder = find.byType(FlowyTextButton);
final foundBtnText =
widgetTester.widget<FlowyTextButton>(btnFinder).text;
expect(btnFinder, findsOneWidget);
expect(foundBtnText, shortcut.command);
});
},
);
group(
"taps the button",
() {
testWidgets("opens AlertDialog correctly", (widgetTester) async {
await widgetTester.pumpWidget(
MaterialApp(
home: ShortcutsListTile(shortcutEvent: shortcut),
),
);
final btnFinder = find.byType(FlowyTextButton);
final foundBtnText =
widgetTester.widget<FlowyTextButton>(btnFinder).text;
expect(btnFinder, findsOneWidget);
expect(foundBtnText, shortcut.command);
await widgetTester.tap(btnFinder);
await widgetTester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.byType(RawKeyboardListener), findsOneWidget);
});
testWidgets("updates the text with new key event",
(widgetTester) async {
await widgetTester.pumpWidget(
MaterialApp(
home: ShortcutsListTile(shortcutEvent: shortcut),
),
);
final btnFinder = find.byType(FlowyTextButton);
await widgetTester.tap(btnFinder);
await widgetTester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.byType(RawKeyboardListener), findsOneWidget);
await widgetTester.sendKeyEvent(LogicalKeyboardKey.keyC);
expect(find.text('c'), findsOneWidget);
});
},
);
});
}

View File

@ -0,0 +1,78 @@
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
KeyEventResult dummyHandler(EditorState e) => KeyEventResult.handled;
final dummyShortcuts = [
CommandShortcutEvent(
key: 'Copy',
command: 'ctrl+c',
handler: dummyHandler,
),
CommandShortcutEvent(
key: 'Paste',
command: 'ctrl+v',
handler: dummyHandler,
),
CommandShortcutEvent(
key: 'Undo',
command: 'ctrl+z',
handler: dummyHandler,
),
CommandShortcutEvent(
key: 'Redo',
command: 'ctrl+y',
handler: dummyHandler,
),
];
group("ShortcutsListView", () {
group("should be displayed correctly", () {
testWidgets("with empty shortcut list", (widgetTester) async {
await widgetTester.pumpWidget(
const MaterialApp(
home: ShortcutsListView(shortcuts: []),
),
);
expect(find.byType(FlowyText), findsNWidgets(3));
//we expect three text widgets which are keybinding, command, and reset
expect(find.byType(ListView), findsOneWidget);
expect(find.byType(ShortcutsListTile), findsNothing);
});
testWidgets("with 1 item in shortcut list", (widgetTester) async {
await widgetTester.pumpWidget(
MaterialApp(
home: ShortcutsListView(shortcuts: [dummyShortcuts[0]]),
),
);
await widgetTester.pumpAndSettle();
expect(find.byType(FlowyText), findsAtLeastNWidgets(3));
expect(find.byType(ListView), findsOneWidget);
expect(find.byType(ShortcutsListTile), findsOneWidget);
});
testWidgets("with populated shortcut list", (widgetTester) async {
await widgetTester.pumpWidget(
MaterialApp(
home: ShortcutsListView(shortcuts: dummyShortcuts),
),
);
expect(find.byType(FlowyText), findsAtLeastNWidgets(3));
expect(find.byType(ListView), findsOneWidget);
expect(
find.byType(ShortcutsListTile),
findsNWidgets(dummyShortcuts.length),
);
});
});
});
}