// Important note on these tests: // // The way the Zulip hotkey tests work is as follows. First, we set // up various contexts by monkey-patching the various hotkeys exports // functions (like overlays.settings_open). Within that context, to // test whether a given key (e.g. `x`) results in a specific function // (e.g. `ui.foo()`), we fail to import any modules other than // hotkey.js so that accessing them will result in a ReferenceError. // // Then we create a stub `ui.foo`, and call the hotkey function. If // it calls any external module other than `ui.foo`, it'll crash. // Future work includes making sure it actually does call `ui.foo()`. set_global('activity', { }); set_global('navigator', { platform: '', }); set_global('page_params', { }); set_global('overlays', { }); const noop = () => {}; // jQuery stuff should go away if we make an initialize() method. set_global('document', 'document-stub'); set_global('$', global.make_zjquery()); $.fn.keydown = noop; $.fn.keypress = noop; zrequire('emoji'); const hotkey = zrequire('hotkey'); zrequire('common'); set_global('list_util', { }); set_global('current_msg_list', { selected_id: function () { return 42; }, selected_message: function () { return { sent_by_me: true, flags: ["read", "starred"], }; }, selected_row: function () {}, get_row: function () { return 101; }, }); function return_true() { return true; } function return_false() { return false; } function stubbing(func_name_to_stub, test_function) { global.with_overrides(function (override) { global.with_stub(function (stub) { override(func_name_to_stub, stub.f); test_function(stub); }); }); } run_test('mappings', () => { function map_press(which, shiftKey) { return hotkey.get_keypress_hotkey({ which: which, shiftKey: shiftKey, }); } function map_down(which, shiftKey, ctrlKey, metaKey) { return hotkey.get_keydown_hotkey({ which: which, shiftKey: shiftKey, ctrlKey: ctrlKey, metaKey: metaKey, }); } // The next assertion protects against an iOS bug where we // treat "!" as a hotkey, because iOS sends the wrong code. assert.equal(map_press(33), undefined); // Test page-up does work. assert.equal(map_down(33).name, 'page_up'); // Test other mappings. assert.equal(map_down(9).name, 'tab'); assert.equal(map_down(9, true).name, 'shift_tab'); assert.equal(map_down(27).name, 'escape'); assert.equal(map_down(37).name, 'left_arrow'); assert.equal(map_down(13).name, 'enter'); assert.equal(map_down(46).name, 'delete'); assert.equal(map_down(13, true).name, 'enter'); assert.equal(map_press(47).name, 'search'); // slash assert.equal(map_press(106).name, 'vim_down'); // j assert.equal(map_down(219, false, true).name, 'escape'); // ctrl + [ assert.equal(map_down(67, false, true).name, 'copy_with_c'); // ctrl + c assert.equal(map_down(75, false, true).name, 'search_with_k'); // ctrl + k assert.equal(map_down(83, false, true).name, 'star_message'); // ctrl + s assert.equal(map_down(190, false, true).name, 'narrow_to_compose_target'); // ctrl + . // More negative tests. assert.equal(map_down(47), undefined); assert.equal(map_press(27), undefined); assert.equal(map_down(27, true), undefined); assert.equal(map_down(86, false, true), undefined); // ctrl + v assert.equal(map_down(90, false, true), undefined); // ctrl + z assert.equal(map_down(84, false, true), undefined); // ctrl + t assert.equal(map_down(82, false, true), undefined); // ctrl + r assert.equal(map_down(79, false, true), undefined); // ctrl + o assert.equal(map_down(80, false, true), undefined); // ctrl + p assert.equal(map_down(65, false, true), undefined); // ctrl + a assert.equal(map_down(70, false, true), undefined); // ctrl + f assert.equal(map_down(72, false, true), undefined); // ctrl + h assert.equal(map_down(88, false, true), undefined); // ctrl + x assert.equal(map_down(78, false, true), undefined); // ctrl + n assert.equal(map_down(77, false, true), undefined); // ctrl + m assert.equal(map_down(67, false, false, true), undefined); // cmd + c assert.equal(map_down(75, false, false, true), undefined); // cmd + k assert.equal(map_down(83, false, false, true), undefined); // cmd + s assert.equal(map_down(75, true, true), undefined); // shift + ctrl + k assert.equal(map_down(83, true, true), undefined); // shift + ctrl + s assert.equal(map_down(219, true, true, false), undefined); // shift + ctrl + [ // CMD tests for MacOS global.navigator.platform = "MacIntel"; assert.equal(map_down(219, false, true, false).name, 'escape'); // ctrl + [ assert.equal(map_down(219, false, false, true), undefined); // cmd + [ assert.equal(map_down(67, false, true, true).name, 'copy_with_c'); // ctrl + c assert.equal(map_down(67, false, true, false), undefined); // cmd + c assert.equal(map_down(75, false, false, true).name, 'search_with_k'); // cmd + k assert.equal(map_down(75, false, true, false), undefined); // ctrl + k assert.equal(map_down(83, false, false, true).name, 'star_message'); // cmd + s assert.equal(map_down(83, false, true, false), undefined); // ctrl + s assert.equal(map_down(190, false, false, true).name, 'narrow_to_compose_target'); // cmd + . assert.equal(map_down(190, false, true, false), undefined); // ctrl + . // Reset platform global.navigator.platform = ''; }); run_test('basic_chars', () => { function process(s) { const e = { which: s.charCodeAt(0), }; try { return hotkey.process_keypress(e); } catch (err) { // An exception will be thrown here if a different // function is called than the one declared. Try to // provide a useful error message. // add a newline to separate from other console output. console.log('\nERROR: Mapping for character "' + e.which + '" does not match tests.'); } } function assert_mapping(c, func_name, shiftKey) { stubbing(func_name, function () { assert(process(c, shiftKey)); }); } function assert_unmapped(s) { for (const c of s) { assert.equal(process(c), false); } } // Unmapped keys should immediately return false, without // calling any functions outside of hotkey.js. assert_unmapped('abfhlmoyz'); assert_unmapped('BEFHILNOQTUWXYZ'); // We have to skip some checks due to the way the code is // currently organized for mapped keys. hotkey.in_content_editable_widget = return_false; overlays.settings_open = return_false; set_global('popovers', { actions_popped: return_false, message_info_popped: return_false, }); set_global('emoji_picker', { reactions_popped: return_false, }); set_global('hotspots', { is_open: return_false, }); set_global('gear_menu', { is_open: return_false, }); // All letters should return false if we are composing text. hotkey.processing_text = return_true; function test_normal_typing() { assert_unmapped('abcdefghijklmnopqrsuvwxyz'); assert_unmapped(' '); assert_unmapped('[]\\.,;'); assert_unmapped('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); assert_unmapped('~!@#$%^*()_+{}:"<>'); } for (const settings_open of [return_true, return_false]) { for (const is_active of [return_true, return_false]) { for (const info_overlay_open of [return_true, return_false]) { set_global('overlays', { is_active: is_active, settings_open: settings_open, info_overlay_open: info_overlay_open, }); test_normal_typing(); } } } // Ok, now test keys that work when we're viewing messages. hotkey.processing_text = return_false; overlays.settings_open = return_false; overlays.streams_open = return_false; overlays.lightbox_open = return_false; overlays.drafts_open = return_false; overlays.recent_topics = return_false; page_params.can_create_streams = true; overlays.streams_open = return_true; overlays.is_active = return_true; assert_mapping('S', 'subs.keyboard_sub'); assert_mapping('V', 'subs.view_stream'); assert_mapping('n', 'subs.open_create_stream'); page_params.can_create_streams = false; assert_unmapped('n'); overlays.streams_open = return_false; test_normal_typing(); overlays.is_active = return_false; assert_mapping('?', 'info_overlay.maybe_show_keyboard_shortcuts'); assert_mapping('/', 'search.initiate_search'); assert_mapping('w', 'activity.initiate_search'); assert_mapping('q', 'stream_list.initiate_search'); assert_mapping('A', 'narrow.stream_cycle_backward'); assert_mapping('D', 'narrow.stream_cycle_forward'); assert_mapping('c', 'compose_actions.start'); assert_mapping('x', 'compose_actions.start'); assert_mapping('P', 'narrow.by'); assert_mapping('g', 'gear_menu.open'); overlays.is_active = return_true; overlays.drafts_open = return_true; assert_mapping('d', 'overlays.close_overlay'); overlays.drafts_open = return_false; test_normal_typing(); overlays.is_active = return_false; assert_mapping('d', 'drafts.launch'); // Test opening and closing of Recent Topics overlays.is_active = return_true; overlays.recent_topics_open = return_true; assert_mapping('t', 'overlays.close_overlay'); overlays.recent_topics_open = return_false; test_normal_typing(); overlays.is_active = return_false; assert_mapping('t', 'hashchange.go_to_location'); // Next, test keys that only work on a selected message. const message_view_only_keys = '@+>RjJkKsSuvi:GM'; // Check that they do nothing without a selected message global.current_msg_list.empty = return_true; assert_unmapped(message_view_only_keys); global.current_msg_list.empty = return_false; // Check that they do nothing while in the settings overlay overlays.settings_open = return_true; assert_unmapped('@*+->rRjJkKsSuvi:GM'); overlays.settings_open = return_false; // TODO: Similar check for being in the subs page assert_mapping('@', 'compose_actions.reply_with_mention'); assert_mapping('+', 'reactions.toggle_emoji_reaction'); assert_mapping('-', 'condense.toggle_collapse'); assert_mapping('r', 'compose_actions.respond_to_message'); assert_mapping('R', 'compose_actions.respond_to_message', true); assert_mapping('j', 'navigate.down'); assert_mapping('J', 'navigate.page_down'); assert_mapping('k', 'navigate.up'); assert_mapping('K', 'navigate.page_up'); assert_mapping('s', 'narrow.by_recipient'); assert_mapping('S', 'narrow.by_topic'); assert_mapping('u', 'popovers.show_sender_info'); assert_mapping('i', 'popovers.open_message_menu'); assert_mapping(':', 'reactions.open_reactions_popover', true); assert_mapping('>', 'compose_actions.quote_and_reply'); assert_mapping('e', 'message_edit.start'); overlays.is_active = return_true; overlays.lightbox_open = return_true; assert_mapping('v', 'overlays.close_overlay'); overlays.lightbox_open = return_false; test_normal_typing(); overlays.is_active = return_false; assert_mapping('v', 'lightbox.show_from_selected_message'); global.emoji_picker.reactions_popped = return_true; assert_mapping(':', 'emoji_picker.navigate', true); global.emoji_picker.reactions_popped = return_false; assert_mapping('G', 'navigate.to_end'); assert_mapping('M', 'muting_ui.toggle_mute'); // Test keys that work when a message is selected and // also when the message list is empty. assert_mapping('n', 'narrow.narrow_to_next_topic'); assert_mapping('p', 'narrow.narrow_to_next_pm_string'); global.current_msg_list.empty = return_true; assert_mapping('n', 'narrow.narrow_to_next_topic'); global.current_msg_list.empty = return_false; }); run_test('motion_keys', () => { const codes = { down_arrow: 40, end: 35, home: 36, left_arrow: 37, right_arrow: 39, page_up: 33, page_down: 34, spacebar: 32, up_arrow: 38, '+': 187, }; function process(name, shiftKey, ctrlKey) { const e = { which: codes[name], shiftKey: shiftKey, ctrlKey: ctrlKey, }; try { return hotkey.process_keydown(e); } catch (err) { // An exception will be thrown here if a different // function is called than the one declared. Try to // provide a useful error message. // add a newline to separate from other console output. console.log('\nERROR: Mapping for character "' + e.which + '" does not match tests.'); } } function assert_unmapped(name) { assert.equal(process(name), false); } function assert_mapping(key_name, func_name, shiftKey, ctrlKey) { stubbing(func_name, function () { assert(process(key_name, shiftKey, ctrlKey)); }); } list_util.inside_list = return_false; global.current_msg_list.empty = return_true; overlays.settings_open = return_false; overlays.streams_open = return_false; overlays.lightbox_open = return_false; assert_unmapped('down_arrow'); assert_unmapped('end'); assert_unmapped('home'); assert_unmapped('page_up'); assert_unmapped('page_down'); assert_unmapped('spacebar'); assert_unmapped('up_arrow'); global.list_util.inside_list = return_true; assert_mapping('up_arrow', 'list_util.go_up'); assert_mapping('down_arrow', 'list_util.go_down'); list_util.inside_list = return_false; global.current_msg_list.empty = return_false; assert_mapping('down_arrow', 'navigate.down'); assert_mapping('end', 'navigate.to_end'); assert_mapping('home', 'navigate.to_home'); assert_mapping('left_arrow', 'message_edit.edit_last_sent_message'); assert_mapping('page_up', 'navigate.page_up'); assert_mapping('page_down', 'navigate.page_down'); assert_mapping('spacebar', 'navigate.page_down'); assert_mapping('up_arrow', 'navigate.up'); overlays.info_overlay_open = return_true; assert_unmapped('down_arrow'); assert_unmapped('up_arrow'); overlays.info_overlay_open = return_false; overlays.streams_open = return_true; assert_mapping('up_arrow', 'subs.switch_rows'); assert_mapping('down_arrow', 'subs.switch_rows'); overlays.streams_open = return_false; overlays.lightbox_open = return_true; assert_mapping('left_arrow', 'lightbox.prev'); assert_mapping('right_arrow', 'lightbox.next'); overlays.lightbox_open = return_false; hotkey.in_content_editable_widget = return_true; assert_unmapped('down_arrow'); assert_unmapped('up_arrow'); hotkey.in_content_editable_widget = return_false; overlays.settings_open = return_true; assert_unmapped('end'); assert_unmapped('home'); assert_unmapped('left_arrow'); assert_unmapped('page_up'); assert_unmapped('page_down'); assert_unmapped('spacebar'); overlays.settings_open = return_false; overlays.is_active = return_true; overlays.drafts_open = return_true; assert_mapping('up_arrow', 'drafts.drafts_handle_events'); assert_mapping('down_arrow', 'drafts.drafts_handle_events'); overlays.is_active = return_false; overlays.drafts_open = return_false; });