"use strict"; const assert = require("node:assert/strict"); const {make_realm} = require("./lib/example_realm.cjs"); const {make_stream} = require("./lib/example_stream.cjs"); const {make_user} = require("./lib/example_user.cjs"); const {mock_esm, zrequire} = require("./lib/namespace.cjs"); const {run_test, noop} = require("./lib/test.cjs"); const {page_params} = require("./lib/zpage_params.cjs"); const narrow_state = mock_esm("../src/narrow_state"); const stream_topic_history_util = mock_esm("../src/stream_topic_history_util"); const direct_message_group_data = zrequire("direct_message_group_data"); const {Filter} = zrequire("filter"); const stream_data = zrequire("stream_data"); const stream_topic_history = zrequire("stream_topic_history"); const people = zrequire("people"); const search = zrequire("search_suggestion"); const {set_current_user, set_realm} = zrequire("state_data"); const current_user = {}; set_current_user(current_user); const realm = make_realm(); set_realm(realm); const me = make_user({ email: "myself@zulip.com", user_id: 41, full_name: "Me Myself", }); const bob = make_user({ email: "bob@zulip.com", user_id: 42, full_name: "Bob Roberts", }); const ted = make_user({ email: "ted@zulip.com", delivery_email: "ted@zulip.com", user_id: 101, full_name: "Ted Smith", }); const alice = make_user({ email: "alice@zulip.com", user_id: 102, full_name: "Alice Ignore", }); const jeff = make_user({ email: "jeff@zulip.com", user_id: 103, full_name: "Jeff Zoolipson", }); const example_avatar_url = "http://example.com/example.png"; let _stream_id = 0; function new_stream_id() { _stream_id += 1; return _stream_id; } function init({override}) { override(current_user, "is_admin", true); // Add users to `valid_user_ids`. const source = "server_events"; people.init(); people.add_active_user(bob, source); people.add_active_user(me, source); people.add_active_user(ted, source); people.add_active_user(alice, source); people.add_active_user(jeff, source); people.initialize_current_user(me.user_id); stream_topic_history.reset(); direct_message_group_data.clear_for_testing(); stream_data.clear_subscriptions(); } function get_suggestions(query, pill_query = "") { return search.get_suggestions( Filter.parse(pill_query).map((suggestion) => Filter.convert_suggestion_to_term(suggestion)), Filter.parse(query), ); } function test(label, f) { run_test(label, (helpers) => { init(helpers); f(helpers); }); } test("person_multi_word_completion", () => { // The help center documents searching person operators by full name, // e.g. `sender:Elena García` and the multi-user `dm:Bo Lin, Elena // García`. An unquoted multi-word name parses with a trailing `search:` // term, which get_suggestions merges back into the person operand before // resolving each name to a user id. assert.deepEqual(get_suggestions("sender:Ted Smith"), [`sender:${ted.user_id}`]); assert.deepEqual(get_suggestions("mentions:Ted Smith"), [`mentions:${ted.user_id}`]); assert.deepEqual(get_suggestions("dm:Ted Smith"), [`dm:${ted.user_id}`]); assert.deepEqual(get_suggestions("dm-including:Ted Smith"), [`dm-including:${ted.user_id}`]); // dm and dm-including resolve a comma-separated list of names, with or // without a space after the comma. assert.deepEqual(get_suggestions("dm:Ted Smith, Alice Ignore"), [ `dm:${ted.user_id},${alice.user_id}`, ]); assert.deepEqual(get_suggestions("dm:Ted Smith,Alice Ignore"), [ `dm:${ted.user_id},${alice.user_id}`, ]); assert.deepEqual(get_suggestions("dm-including:Ted Smith, Alice Ignore"), [ `dm-including:${ted.user_id},${alice.user_id}`, ]); assert.deepEqual(get_suggestions("dm-including:Ted Smith,Alice Ignore"), [ `dm-including:${ted.user_id},${alice.user_id}`, ]); }); test("basic_get_suggestions", ({override}) => { const query = "fred"; override(narrow_state, "stream_id", noop); const suggestions = get_suggestions(query); const expected = ["fred"]; assert.deepEqual(suggestions, expected); }); test("basic_get_suggestions_for_spectator", () => { page_params.is_spectator = true; const web_public_id = new_stream_id(); const sub = make_stream({ name: "Web public", stream_id: web_public_id, is_web_public: true, subscribed: false, }); stream_data.add_sub_for_tests(sub); let query = ""; let suggestions = get_suggestions(query); assert.deepEqual(suggestions, [ "channels:", "channel:", "date:", "is:resolved", "-is:resolved", "has:link", "has:image", "has:attachment", "has:reaction", ]); stream_data.delete_sub(sub.stream_id); query = "channels:"; suggestions = get_suggestions(query); assert.deepEqual(suggestions, ["channels:archived"]); page_params.is_spectator = false; }); test("get_suggestions deduplication", () => { let query = "has:attachment"; let suggestions = get_suggestions(query, query); let expected = ["has:attachment"]; assert.deepEqual(suggestions, expected); query = "has:attachment has:attachment"; suggestions = get_suggestions(query); expected = ["has:attachment"]; assert.deepEqual(suggestions, expected); }); test("get_is_suggestions_for_spectator", () => { page_params.is_spectator = true; const query = "is:"; const suggestions = get_suggestions(query); // The list of suggestions should only contain "is:resolved" for a spectator assert.deepEqual(suggestions, ["is:resolved"]); page_params.is_spectator = false; }); test("dm_suggestions", ({override}) => { let query = "is:dm"; let suggestions = get_suggestions(query); // Empty query: compare_users_for_dms sorts alphabetically // by full_name (no direct message history, and no name-length // tiebreaker without a query). let expected = [ "is:dm", `dm:${alice.user_id}`, // Alice Ignore `dm:${bob.user_id}`, // Bob Roberts `dm:${jeff.user_id}`, // Jeff Zoolipson `dm:${me.user_id}`, // Me Myself `dm:${ted.user_id}`, // Ted Smith ]; assert.deepEqual(suggestions, expected); query = "is:dm al"; suggestions = get_suggestions(query); expected = [ "is:dm al", "is:dm is:alerted", `is:dm dm:${alice.user_id}`, `is:dm sender:${alice.user_id}`, `is:dm dm-including:${alice.user_id}`, `is:dm mentions:${alice.user_id}`, ]; assert.deepEqual(suggestions, expected); // "is:private" was renamed to "is:dm", so // we suggest "is:dm" to anyone with "is:private" // in their muscle memory. query = "is:pr"; suggestions = get_suggestions(query); expected = ["is:dm"]; assert.deepEqual(suggestions, expected); query = "is:private"; suggestions = get_suggestions(query); // Same search suggestions and order as for "is:dm". expected = [ "is:dm", `dm:${alice.user_id}`, // Alice Ignore `dm:${bob.user_id}`, // Bob Roberts `dm:${jeff.user_id}`, // Jeff Zoolipson `dm:${me.user_id}`, // Me Myself `dm:${ted.user_id}`, // Ted Smith ]; assert.deepEqual(suggestions, expected); query = "dm:t"; suggestions = get_suggestions(query); expected = [`dm:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = "-dm:t"; suggestions = get_suggestions(query); expected = [`is:dm -dm:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = `dm:${ted.user_id}`; suggestions = get_suggestions(query); expected = [`dm:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = "sender:ted"; suggestions = get_suggestions(query); expected = [`sender:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = "sender:te"; suggestions = get_suggestions(query); expected = [`sender:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = "-sender:te"; suggestions = get_suggestions(query); expected = [`-sender:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = `sender:${ted.user_id}`; suggestions = get_suggestions(query); expected = [`sender:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = "is:unread from:ted"; suggestions = get_suggestions(query); expected = [`is:unread sender:${ted.user_id}`]; assert.deepEqual(suggestions, expected); // Users can enter bizarre queries, and if they do, we want to // be conservative with suggestions. query = "is:dm near:3"; suggestions = get_suggestions(query); expected = ["is:dm near:3"]; assert.deepEqual(suggestions, expected); query = `dm:${ted.user_id} near:3`; suggestions = get_suggestions(query); expected = [`dm:${ted.user_id} near:3`]; assert.deepEqual(suggestions, expected); // Make sure suggestions still work if preceding tokens query = `is:alerted sender:${ted.user_id}`; suggestions = get_suggestions(query); expected = [`is:alerted sender:${ted.user_id}`]; assert.deepEqual(suggestions, expected); query = "is:starred has:link is:dm al"; suggestions = get_suggestions(query); expected = [ "is:starred has:link is:dm al", "is:starred has:link is:dm is:alerted", `is:starred has:link is:dm dm:${alice.user_id}`, `is:starred has:link is:dm sender:${alice.user_id}`, `is:starred has:link is:dm dm-including:${alice.user_id}`, `is:starred has:link is:dm mentions:${alice.user_id}`, ]; assert.deepEqual(suggestions, expected); query = `from:${ted.user_id}`; suggestions = get_suggestions(query); expected = [`sender:${ted.user_id}`]; assert.deepEqual(suggestions, expected); // "pm-with" operator returns search result // and "dm" operator as a suggestions override(narrow_state, "stream_id", () => undefined); query = "pm-with"; suggestions = get_suggestions(query); expected = ["pm-with", "dm:"]; assert.deepEqual(suggestions, expected); query = "pm-with:t"; suggestions = get_suggestions(query); expected = [`dm:${ted.user_id}`]; assert.deepEqual(suggestions, expected); }); test("dm_suggestions_for_exact_full_name", () => { // When the typed text exactly matches a unique full name, it is // resolved to that user's id internally. We must still suggest other // users whose names match the typed text as a prefix, rather than // collapsing to just the exactly-named user. const john = make_user({ email: "john@zulip.com", user_id: 200, full_name: "John", }); const john_doe = make_user({ email: "john-doe@zulip.com", user_id: 201, full_name: "John Doe", }); people.add_active_user(john, "server_events"); people.add_active_user(john_doe, "server_events"); // A partial query matches both users by name prefix. let suggestions = get_suggestions("dm:joh"); assert.deepEqual(suggestions, [`dm:${john.user_id}`, `dm:${john_doe.user_id}`]); // Completing the unique full name "John" must still suggest "John Doe", // not collapse to just "John". suggestions = get_suggestions("dm:John"); assert.deepEqual(suggestions, [`dm:${john.user_id}`, `dm:${john_doe.user_id}`]); // A multi-word continuation after the resolved name still matches by // name, narrowing to "John Doe". suggestions = get_suggestions("dm:John Do"); assert.deepEqual(suggestions, [`dm:${john_doe.user_id}`]); }); test("group_suggestions", () => { // If there's an existing completed user pill right before // the input string, we suggest a user group as one of the // suggestions. let pill_query = `dm:${bob.user_id}`; let query = "alice"; let suggestions = get_suggestions(query, pill_query); let expected = [ `dm:${bob.user_id} alice`, `dm:${bob.user_id},${alice.user_id}`, `dm:${bob.user_id} sender:${alice.user_id}`, `dm:${bob.user_id} dm-including:${alice.user_id}`, `dm:${bob.user_id} mentions:${alice.user_id}`, ]; assert.deepEqual(suggestions, expected); // Do not suggest "myself@zulip.com" (the name of the current user) for dms pill_query = `dm:${ted.user_id}`; query = "my"; suggestions = get_suggestions(query, pill_query); expected = [ `dm:${ted.user_id} my`, `dm:${ted.user_id} sender:${me.user_id}`, `dm:${ted.user_id} dm-including:${me.user_id}`, `dm:${ted.user_id} mentions:${me.user_id}`, ]; assert.deepEqual(suggestions, expected); // "is:dm" should be properly prepended to each suggestion // if the "dm" operator is negated. query = "-dm:bob@zulip.co"; suggestions = get_suggestions(query); expected = [`is:dm -dm:${bob.user_id}`]; assert.deepEqual(suggestions, expected); // If user types "pm-with" operator, show suggestions for // group direct messages with the "dm" operator. pill_query = `pm-with:${bob.user_id}`; query = "alice"; suggestions = get_suggestions(query, pill_query); expected = [ `dm:${bob.user_id} alice`, `dm:${bob.user_id},${alice.user_id}`, `dm:${bob.user_id} sender:${alice.user_id}`, `dm:${bob.user_id} dm-including:${alice.user_id}`, `dm:${bob.user_id} mentions:${alice.user_id}`, ]; assert.deepEqual(suggestions, expected); // Test multiple terms pill_query = `is:starred has:link dm:${bob.user_id}`; query = "Smit"; suggestions = get_suggestions(query, pill_query); expected = [ `is:starred has:link dm:${bob.user_id} Smit`, `is:starred has:link dm:${bob.user_id},${ted.user_id}`, `is:starred has:link dm:${bob.user_id} sender:${ted.user_id}`, `is:starred has:link dm:${bob.user_id} dm-including:${ted.user_id}`, `is:starred has:link dm:${bob.user_id} mentions:${ted.user_id}`, ]; assert.deepEqual(suggestions, expected); // Doesn't show dms because it's invalid in combination // with a channel. (Random channel id.) stream_data.add_sub_for_tests( make_stream({ stream_id: 66, name: "misc", subscribed: true, }), ); query = `channel:66 has:link dm:${bob.user_id},Smit`; suggestions = get_suggestions(query); expected = []; assert.deepEqual(suggestions, expected); // Invalid emails don't give suggestions query = "dm:invalid@zulip.com,Smit"; suggestions = get_suggestions(query); expected = []; assert.deepEqual(suggestions, expected); }); test("empty_query_suggestions", () => { const query = ""; const devel_id = new_stream_id(); const office_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: devel_id, name: "devel", subscribed: true, is_web_public: true, }), ); stream_data.add_sub_for_tests( make_stream({ stream_id: office_id, name: "office", subscribed: true, }), ); const suggestions = get_suggestions(query); const expected = [ "channels:", "channel:", "date:", "is:dm", "is:starred", "is:mentioned", "is:followed", "is:alerted", "is:unread", "is:muted", "is:resolved", "-is:resolved", `sender:${me.user_id}`, `channel:${devel_id}`, `channel:${office_id}`, "has:link", "has:image", "has:attachment", "has:reaction", ]; assert.deepEqual(suggestions, expected); }); test("has_suggestions", ({override}) => { // Checks that category wise suggestions are displayed instead of a single // default suggestion when suggesting `has` operator. let query = "h"; stream_data.add_sub_for_tests( make_stream({ stream_id: 44, name: "devel", subscribed: true, }), ); stream_data.add_sub_for_tests( make_stream({ stream_id: 77, name: "office", subscribed: true, }), ); override(narrow_state, "stream_id", noop); let suggestions = get_suggestions(query); let expected = ["h", "has:link", "has:image", "has:attachment", "has:reaction"]; assert.deepEqual(suggestions, expected); query = "-h"; suggestions = get_suggestions(query); expected = ["-h", "-has:link", "-has:image", "-has:attachment", "-has:reaction"]; assert.deepEqual(suggestions, expected); // operand suggestions follow. query = "has:"; suggestions = get_suggestions(query); expected = ["has:link", "has:image", "has:attachment", "has:reaction"]; assert.deepEqual(suggestions, expected); query = "has:im"; suggestions = get_suggestions(query); expected = ["has:image"]; assert.deepEqual(suggestions, expected); query = "-has:im"; suggestions = get_suggestions(query); expected = ["-has:image"]; assert.deepEqual(suggestions, expected); query = "att"; suggestions = get_suggestions(query); expected = ["att", "has:attachment"]; assert.deepEqual(suggestions, expected); stream_data.add_sub_for_tests( make_stream({ stream_id: 66, name: "misc", subscribed: true, }), ); query = "channel:66 is:alerted has:lin"; suggestions = get_suggestions(query); expected = ["channel:66 is:alerted has:link"]; assert.deepEqual(suggestions, expected); }); test("check_is_suggestions", ({override}) => { override(narrow_state, "stream_id", noop); let query = "i"; let suggestions = get_suggestions(query); let expected = [ "i", "is:dm", "is:starred", "is:mentioned", "is:followed", "is:alerted", "is:unread", "is:muted", "is:resolved", `dm:${alice.user_id}`, `sender:${alice.user_id}`, `dm-including:${alice.user_id}`, `mentions:${alice.user_id}`, "has:image", ]; assert.deepEqual(suggestions, expected); query = "-i"; suggestions = get_suggestions(query); expected = [ "-i", "-is:dm", "-is:starred", "-is:mentioned", "-is:followed", "-is:alerted", "-is:unread", "-is:muted", "-is:resolved", ]; assert.deepEqual(suggestions, expected); // operand suggestions follow. query = "is:"; suggestions = get_suggestions(query); expected = [ "is:dm", "is:starred", "is:mentioned", "is:followed", "is:alerted", "is:unread", "is:muted", "is:resolved", ]; assert.deepEqual(suggestions, expected); query = "is:st"; suggestions = get_suggestions(query); expected = ["is:starred"]; assert.deepEqual(suggestions, expected); query = "-is:st"; suggestions = get_suggestions(query); expected = ["-is:starred"]; assert.deepEqual(suggestions, expected); // Still returns suggestions for "streams:public", // but shows html description used for "channels:public" query = "st"; suggestions = get_suggestions(query); expected = ["st", "channels:", "channel:", "is:starred"]; assert.deepEqual(suggestions, expected); stream_data.add_sub_for_tests( make_stream({ stream_id: 66, name: "misc", subscribed: true, }), ); query = "channel:66 has:link is:sta"; suggestions = get_suggestions(query); expected = ["channel:66 has:link is:starred"]; assert.deepEqual(suggestions, expected); }); test("sent_by_me_suggestions", ({override}) => { override(narrow_state, "stream_id", noop); override(stream_topic_history_util, "get_server_history", noop); let query = ""; let suggestions = get_suggestions(query); assert.ok(suggestions.includes(`sender:${me.user_id}`)); query = "sender"; suggestions = get_suggestions(query); let expected = ["sender", "sender:", `sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); query = "-sender"; suggestions = get_suggestions(query); expected = ["-sender", "-sender:", `-sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); query = "from"; suggestions = get_suggestions(query); expected = ["from", "sender:", `sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); query = "-from"; suggestions = get_suggestions(query); expected = ["-from", "-sender:", `-sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); query = `sender:${bob.user_id}`; suggestions = get_suggestions(query); expected = [`sender:${bob.user_id}`]; assert.deepEqual(suggestions, expected); query = `from:${bob.user_id}`; suggestions = get_suggestions(query); expected = [`sender:${bob.user_id}`]; assert.deepEqual(suggestions, expected); query = "sent"; suggestions = get_suggestions(query); expected = ["sent", `sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); query = "-sent"; suggestions = get_suggestions(query); expected = ["-sent", `-sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); const denmark_id = new_stream_id(); const sub = make_stream({ name: "Denmark", stream_id: denmark_id, }); stream_data.add_sub_for_tests(sub); // `sent` is an exact keyword shorthand for the sent-by-me // suggestion (`get_sent_by_me_suggestions` → `sender:me`), which // tier 2 surfaces above the literal-input row. query = `channel:${denmark_id} topic:Denmark1 sent`; suggestions = get_suggestions(query); expected = [ `channel:${denmark_id} topic:Denmark1 sender:${me.user_id}`, `channel:${denmark_id} topic:Denmark1 sent`, ]; assert.deepEqual(suggestions, expected); query = "is:starred sender:m"; suggestions = get_suggestions(query); expected = [`is:starred sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); }); test("topic_suggestions", ({override, override_rewire}) => { let suggestions; let expected; override(stream_topic_history_util, "get_server_history", noop); const office_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: office_id, name: "office", subscribed: true, }), ); override(narrow_state, "stream_id", () => office_id); const devel_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: devel_id, name: "devel", subscribed: true, }), ); stream_data.add_sub_for_tests( make_stream({ stream_id: office_id, name: "office", subscribed: true, }), ); suggestions = get_suggestions("te"); expected = [ "te", `dm:${ted.user_id}`, `sender:${ted.user_id}`, `dm-including:${ted.user_id}`, `mentions:${ted.user_id}`, ]; assert.deepEqual(suggestions, expected); stream_topic_history.add_message({ stream_id: devel_id, topic_name: "REXX", }); for (const topic_name of ["team", "ignore", "✔ ice cream", "✔ team work", "test"]) { stream_topic_history.add_message({ stream_id: office_id, topic_name, }); } suggestions = get_suggestions("te"); expected = [ "te", `dm:${ted.user_id}`, `sender:${ted.user_id}`, `dm-including:${ted.user_id}`, `mentions:${ted.user_id}`, `channel:${office_id} topic:team`, `channel:${office_id} topic:✔+team+work`, `channel:${office_id} topic:test`, ]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions(`topic:staplers channel:${office_id}`); expected = [`topic:staplers channel:${office_id}`]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions(`channel:${devel_id} topic:`); expected = [`channel:${devel_id} topic:`, `channel:${devel_id} topic:REXX`]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions(`channel:${devel_id} -topic:`); expected = [`channel:${devel_id} -topic:`, `channel:${devel_id} -topic:REXX`]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions("-topic:te"); expected = [ "-topic:te", `channel:${office_id} -topic:team`, `channel:${office_id} -topic:✔+team+work`, `channel:${office_id} -topic:test`, ]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions("topic:", `-channel:${office_id}`); expected = [ `-channel:${office_id} topic:`, `-channel:${office_id} channel:${devel_id} topic:REXX`, ]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions("topic:", `-channel:${office_id} -channel:${devel_id}`); expected = [`-channel:${office_id} -channel:${devel_id} topic:`]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions(`is:alerted channel:${devel_id} is:starred topic:`); expected = [ `is:alerted channel:${devel_id} is:starred topic:`, `is:alerted channel:${devel_id} is:starred topic:REXX`, ]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions(`is:dm channel:${devel_id} topic:`); expected = [`is:dm channel:${devel_id} topic:`]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions(`topic:REXX channel:${devel_id} topic:`); expected = [`topic:REXX channel:${devel_id} topic:`]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions("topic:"); expected = [ "topic:", "channel:5 topic:✔+ice+cream", "channel:5 topic:ignore", `channel:5 topic:team`, "channel:5 topic:✔+team+work", `channel:5 topic:test`, "channel:6 topic:REXX", ]; assert.deepEqual(suggestions, expected); override(narrow_state, "stream_id", () => ""); for (const topic_name of ["a", "b", "c", "trap", "talks", "tower"]) { stream_topic_history.add_message({ stream_id: devel_id, topic_name, }); } override_rewire(stream_data, "set_max_channel_width_css_variable", noop); stream_data.subscribe_myself(stream_data.get_sub("devel")); stream_data.subscribe_myself(stream_data.get_sub("office")); suggestions = get_suggestions("topic:"); expected = [ "topic:", "channel:6 topic:a", "channel:6 topic:b", "channel:6 topic:c", "channel:5 topic:✔+ice+cream", "channel:5 topic:ignore", "channel:6 topic:REXX", "channel:5 topic:team", "channel:5 topic:✔+team+work", "channel:5 topic:test", "channel:6 topic:trap", ]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions("topic:t"); expected = [ "topic:t", "channel:5 topic:team", "channel:5 topic:✔+team+work", "channel:5 topic:test", "channel:6 topic:trap", "channel:6 topic:talks", "channel:6 topic:tower", ]; assert.deepEqual(suggestions, expected); // Prioritize topics from currently narrowed channel override(narrow_state, "stream_id", () => devel_id); suggestions = get_suggestions("topic:t"); expected = [ "topic:t", "channel:6 topic:trap", "channel:6 topic:talks", "channel:6 topic:tower", "channel:5 topic:team", "channel:5 topic:✔+team+work", "channel:5 topic:test", ]; assert.deepEqual(suggestions, expected); override(narrow_state, "stream_id", () => office_id); suggestions = get_suggestions("topic:t"); expected = [ "topic:t", "channel:5 topic:team", "channel:5 topic:✔+team+work", "channel:5 topic:test", "channel:6 topic:trap", "channel:6 topic:talks", "channel:6 topic:tower", ]; assert.deepEqual(suggestions, expected); // This is the case where the last term's // operand is not a stringified channel id. // We avoid suggesting topics in this case. suggestions = get_suggestions("channel:foobar"); expected = []; assert.deepEqual(suggestions, expected); // We shouldn't see topic suggestions for the case // where the last term is a channel with an operand // that couldn't be converted to a string id by the // Filter.parse step. suggestions = get_suggestions("hello channel:foobar"); expected = []; assert.deepEqual(suggestions, expected); // We shouldn't see topic suggestions for an // empty operand with channel as the operator for // the last term. suggestions = get_suggestions("hello channel:"); expected = ["hello channel:5", "hello channel:6"]; assert.deepEqual(suggestions, expected); }); test("topic_suggestions (limits)", () => { let candidate_topic_entries = []; function wrap(topics) { return topics.map((t) => ({channel_id: "1", topic: t})); } function assert_result(guess, expected_topic_strings) { assert.deepEqual( search.get_topic_suggestions_from_candidates({ candidate_topic_entries, guess, }), wrap(expected_topic_strings), ); } assert_result("", []); assert_result("zzz", []); candidate_topic_entries = wrap(["a", "b", "c"]); assert_result("", ["a", "b", "c"]); assert_result("b", ["b"]); assert_result("z", []); candidate_topic_entries = wrap([ "a1", "a2", "b1", "b2", "a3", "a4", "a5", "c1", "a6", "a7", "a8", "c2", "a9", "a10", "a11", "a12", ]); // We max out at 10 topics, so as not to overwhelm the user. assert_result("", ["a1", "a2", "b1", "b2", "a3", "a4", "a5", "c1", "a6", "a7"]); assert_result("a", ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10"]); assert_result("b", ["b1", "b2"]); assert_result("z", []); }); test("whitespace_glitch", ({override}) => { const office_stream_id = new_stream_id(); const query = "channel:office "; // note trailing space override(stream_topic_history_util, "get_server_history", noop); stream_data.add_sub_for_tests( make_stream({ stream_id: office_stream_id, name: "office", subscribed: true, }), ); const suggestions = get_suggestions(query); const expected = [`channel:${office_stream_id}`]; assert.deepEqual(suggestions, expected); }); test("channel_completion", ({override}) => { const office_stream_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: office_stream_id, name: "office", subscribed: true, }), ); const dev_help_stream_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: dev_help_stream_id, name: "dev help", subscribed: true, }), ); override(narrow_state, "stream_id", noop); let query = "channel:of"; let suggestions = get_suggestions(query); let expected = [`channel:${office_stream_id}`]; assert.deepEqual(suggestions, expected); query = "-channel:of"; suggestions = get_suggestions(query); expected = [`-channel:${office_stream_id}`]; assert.deepEqual(suggestions, expected); query = "hel"; suggestions = get_suggestions(query); expected = ["hel", `channel:${dev_help_stream_id}`]; assert.deepEqual(suggestions, expected); }); test("channel_exact_name_surfaces_siblings", ({override}) => { override(stream_topic_history_util, "get_server_history", noop); const core_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: core_id, name: "core", subscribed: true}), ); const core_team_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: core_team_id, name: "core team", subscribed: true}), ); // Typing the exact name "core" resolves to its stream_id via // `Filter.parse`, but we still phrase-match the name so the // sibling channel "core team" surfaces as an alternative // interpretation, just as it would for the still-incomplete // "channel:cor". let query = "channel:core"; let suggestions = get_suggestions(query); let expected = [`channel:${core_id}`, `channel:${core_team_id}`]; assert.deepEqual(suggestions, expected); // The still-incomplete name behaves identically, confirming the // exact-match case isn't a special-cased regression. query = "channel:cor"; suggestions = get_suggestions(query); expected = [`channel:${core_id}`, `channel:${core_team_id}`]; assert.deepEqual(suggestions, expected); // A numeric operand resolves to that stream_id's channel, but we // also match it as literally typed, so `channel:7` surfaces both // #Venice (id 7) and a channel whose name starts with "7". stream_data.add_sub_for_tests(make_stream({stream_id: 7, name: "Venice", subscribed: true})); const seventh_floor_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: seventh_floor_id, name: "7th floor", subscribed: true}), ); query = "channel:7"; suggestions = get_suggestions(query); expected = ["channel:7", `channel:${seventh_floor_id}`]; assert.deepEqual(suggestions, expected); // "Venice" (id 7) has a genuine sibling "Venice beach". Typing the // exact name resolves the operand to the id "7", so we match both // the name "Venice" and the literal "7". The genuine sibling // "Venice beach" (a name match) must rank above "7th floor" (only a // coincidental match on the id-7 literal). const venice_beach_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: venice_beach_id, name: "Venice beach", subscribed: true}), ); query = "channel:Venice"; suggestions = get_suggestions(query); expected = ["channel:7", `channel:${venice_beach_id}`, `channel:${seventh_floor_id}`]; assert.deepEqual(suggestions, expected); }); test("channel_multi_word_completion", ({override}) => { override(narrow_state, "stream_id", noop); override(stream_topic_history_util, "get_server_history", noop); const automated_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: automated_id, name: "automated", subscribed: true}), ); const automated_testing_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: automated_testing_id, name: "automated testing", subscribed: true, }), ); const q1_automated_testing_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: q1_automated_testing_id, name: "Q1 automated testing", subscribed: true, }), ); // Strong multi-word match ("automated testing" starts with the // typed query) ranks above the literal-input row, weak match ("Q1 // automated testing" matches but doesn't start with the query) // ranks below. let query = "channel:automated testing"; let suggestions = get_suggestions(query); let expected = [ `channel:${automated_testing_id}`, `channel:${automated_id} testing`, `channel:${q1_automated_testing_id}`, ]; assert.deepEqual(suggestions, expected); // Trailing fragment is a prefix of `has:*` with no multi-word // match, so tier 2 surfaces those completions above the // literal-input row. Only fires because the single-word channel // `automated` resolves. query = "channel:automated h"; suggestions = get_suggestions(query); expected = [ `channel:${automated_id} has:link`, `channel:${automated_id} has:image`, `channel:${automated_id} has:attachment`, `channel:${automated_id} has:reaction`, `channel:${automated_id} h`, ]; assert.deepEqual(suggestions, expected); // Single-word channel doesn't resolve. Strong multi-word match // floats to the top. Tier 3 drops the un-resolvable channel and // shows just the trailing fragment. The post-tier filterer loop // also offers channels matching the trailing fragment alone. query = "channel:Q1 automated"; suggestions = get_suggestions(query); expected = [ `channel:${q1_automated_testing_id}`, "automated", `channel:${automated_id}`, `channel:${automated_testing_id}`, ]; assert.deepEqual(suggestions, expected); // No multi-word match and the typed channel doesn't resolve: tier // 3 row degrades to just the trailing fragment. query = "channel:nonexistent foo"; suggestions = get_suggestions(query); expected = ["foo"]; assert.deepEqual(suggestions, expected); // Resolved single-word channel with no multi-word match: tier 3 // shows the resolved channel + free-text search. query = "channel:automated nonexistent"; suggestions = get_suggestions(query); expected = [`channel:${automated_id} nonexistent`]; assert.deepEqual(suggestions, expected); // When the channel resolves, the post-tier filterer loop runs with // the resolved-channel context, surfacing e.g. topic suggestions. stream_topic_history.add_message({stream_id: automated_id, topic_name: "release prep"}); query = "channel:automated release"; suggestions = get_suggestions(query); expected = [`channel:${automated_id} release`, `channel:${automated_id} topic:release+prep`]; assert.deepEqual(suggestions, expected); // `se` is an operator-prefix fragment: tier 2 surfaces both the // `sender:` operator completion and the sent-by-me completion. query = "channel:automated se"; suggestions = get_suggestions(query); expected = [ `channel:${automated_id} sender:`, `channel:${automated_id} sender:${me.user_id}`, `channel:${automated_id} se`, ]; assert.deepEqual(suggestions, expected); // With the same channel already pilled, the tier-3 row shows the // channel only once, and multi-word channel matches (which would // add a second channel term) are suppressed as invalid. suggestions = get_suggestions("channel:automated testing", "channel:automated"); expected = [`channel:${automated_id} testing`]; assert.deepEqual(suggestions, expected); // An `is:dm` pill is incompatible with channel terms, so no // multi-word channel matches are offered; only the literal input // row remains. suggestions = get_suggestions("channel:automated testing", "is:dm"); expected = [`is:dm channel:${automated_id} testing`]; assert.deepEqual(suggestions, expected); // When the trailing fragment is both a strong multi-word match and // an operator prefix ("h" prefixes `has:*`), the multi-word channel // match ("deploy hooks") ranks above the operator completions: a // two-word channel name is the likelier intent than starting a new // operator. const deploy_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: deploy_id, name: "deploy", subscribed: true}), ); const deploy_hooks_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: deploy_hooks_id, name: "deploy hooks", subscribed: true}), ); suggestions = get_suggestions("channel:deploy h"); expected = [ `channel:${deploy_hooks_id}`, `channel:${deploy_id} has:link`, `channel:${deploy_id} has:image`, `channel:${deploy_id} has:attachment`, `channel:${deploy_id} has:reaction`, `channel:${deploy_id} h`, ]; assert.deepEqual(suggestions, expected); }); test("channel_multi_word_renamed_completion", ({override, override_rewire}) => { override(narrow_state, "stream_id", noop); override_rewire(stream_data, "set_max_channel_width_css_variable", noop); const pipelines_id = new_stream_id(); const pipelines_sub = make_stream({ stream_id: pipelines_id, name: "pipeline", subscribed: true, }); stream_data.add_sub_for_tests(pipelines_sub); stream_data.rename_sub(pipelines_sub, "pipelines"); // A channel's pre-rename name is not resolved (matching // `Filter.parse`'s name resolution), so it reads as free text. const suggestions = get_suggestions("channel:pipeline x"); const expected = ["x"]; assert.deepEqual(suggestions, expected); }); test("channel_multi_word_digit_prefix_completion", ({override}) => { override(narrow_state, "stream_id", noop); // "7th floor" starts with digits. `get_sub_by_id_string` accepts // only an exact canonical id, so "7th" isn't read as the unrelated // channel with id 7 — it's matched by name to "7th floor" // (channel:8). "7th" also isn't a valid channel id, so there's no // `channel:7th` literal row. stream_data.add_sub_for_tests(make_stream({stream_id: 7, name: "general", subscribed: true})); stream_data.add_sub_for_tests(make_stream({stream_id: 8, name: "7th floor", subscribed: true})); let suggestions = get_suggestions("channel:7th"); let expected = ["channel:8"]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions("channel:7th fl"); expected = ["channel:8", "fl"]; assert.deepEqual(suggestions, expected); }); test("channel_multi_word_name_starting_with_id", ({override}) => { override(stream_topic_history_util, "get_server_history", noop); // A channel named "15 test" whose leading word ("15") is also // another channel's stream_id. Typing `channel:15 test` resolves // "15" to the id-15 channel for the merged query, but we also match // the query as literally typed, so "15 test" still surfaces by name. stream_data.add_sub_for_tests(make_stream({stream_id: 15, name: "lobby", subscribed: true})); const fifteen_test_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: fifteen_test_id, name: "15 test", subscribed: true}), ); const suggestions = get_suggestions("channel:15 test"); const expected = [`channel:${fifteen_test_id}`, "channel:15 test"]; assert.deepEqual(suggestions, expected); }); test("topic_multi_word_completion", ({override}) => { override(narrow_state, "stream_id", noop); const releases_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: releases_id, name: "releases", subscribed: true}), ); stream_topic_history.add_message({stream_id: releases_id, topic_name: "release notes"}); stream_topic_history.add_message({ stream_id: releases_id, message_id: 1, topic_name: "release hotfix", }); // Strong multi-word topic match (cross-channel, since no channel // pill) ranks above the pinned default-row. let query = "topic:release notes"; let suggestions = get_suggestions(query); let expected = [`channel:${releases_id} topic:release+notes`, "topic:release notes"]; assert.deepEqual(suggestions, expected); // When the trailing fragment is both a strong multi-word match and // an operator prefix ("h" prefixes `has:*`), the multi-word topic // match ("release hotfix") ranks above the operator completions, // which precede the literal-input row. query = "topic:release h"; suggestions = get_suggestions(query); expected = [ `channel:${releases_id} topic:release+hotfix`, "topic:release has:link", "topic:release has:image", "topic:release has:attachment", "topic:release has:reaction", "topic:release h", ]; assert.deepEqual(suggestions, expected); // An `is:dm` pill is incompatible with topic terms, so no // multi-word topic matches are offered; only the literal input // row remains. query = "topic:release notes"; suggestions = get_suggestions(query, "is:dm"); expected = ["is:dm topic:release notes"]; assert.deepEqual(suggestions, expected); // A resolved topic ranks as a strong match: classification ignores // the resolved-topic prefix, like the sort key does. stream_topic_history.add_message({ stream_id: releases_id, message_id: 2, topic_name: "✔ release docs", }); query = "topic:release docs"; suggestions = get_suggestions(query); expected = [`channel:${releases_id} topic:✔+release+docs`, "topic:release docs"]; assert.deepEqual(suggestions, expected); }); test("topic_multi_word_match_budget", ({override}) => { override(stream_topic_history_util, "get_server_history", noop); const narrow_stream_id = new_stream_id(); override(narrow_state, "stream_id", () => narrow_stream_id); stream_data.add_sub_for_tests( make_stream({stream_id: narrow_stream_id, name: "core team", subscribed: true}), ); const releases_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({stream_id: releases_id, name: "releases", subscribed: true}), ); // Enough weak matches in the narrowed channel to exhaust a // 10-topic match budget on their own. for (let i = 1; i <= 10; i += 1) { stream_topic_history.add_message({ stream_id: narrow_stream_id, message_id: i, topic_name: `old release notes ${i}`, }); } stream_topic_history.add_message({ stream_id: releases_id, message_id: 100, topic_name: "release notes", }); // The narrowed channel's topics and other channels' topics each // get their own match budget, so the strong match from another // channel still surfaces; the narrowed channel's weak matches // follow in recency order. const suggestions = get_suggestions("topic:release notes"); const expected = [ `channel:${releases_id} topic:release+notes`, "topic:release notes", ...Array.from( {length: 10}, (_, i) => `channel:${narrow_stream_id} topic:old+release+notes+${10 - i}`, ), ]; assert.deepEqual(suggestions, expected); }); test("people_suggestions", ({override}) => { let query = "te"; override(narrow_state, "stream_id", noop); const bob = { email: "bob@zulip.com", user_id: 202, full_name: "Bob Térry", avatar_url: example_avatar_url, is_guest: false, }; const alice = { email: "alice@zulip.com", user_id: 203, full_name: "Alice Ignore", }; people.add_active_user(ted, "server_events"); people.add_active_user(bob, "server_events"); people.add_active_user(alice, "server_events"); // Add an inaccessible user to verify that it is not included in // suggestions. const inaccessible_user = { user_id: 299, // All inaccessible users are named as "Unknown user", but we name // it differently here so that the name matches the search query. full_name: "Test unknown user", email: "user299@zulipdev.com", is_inaccessible_user: true, }; people._add_user(inaccessible_user); let suggestions = get_suggestions(query); // Both match "te"; compare_users_for_dms called without query // so no name-length tiebreaker — alphabetical by full_name. let expected = [ "te", `dm:${bob.user_id}`, // Bob Térry `dm:${ted.user_id}`, // Ted Smith `sender:${bob.user_id}`, `sender:${ted.user_id}`, `dm-including:${bob.user_id}`, `dm-including:${ted.user_id}`, `mentions:${bob.user_id}`, `mentions:${ted.user_id}`, ]; assert.deepEqual(suggestions, expected); const accessible_user = { user_id: 299, full_name: "Test unknown user", email: "user299@zulipdev.com", }; people.add_active_user(accessible_user, "server_events"); suggestions = get_suggestions(query); // Same order with Test unknown user added (alphabetical). expected = [ "te", `dm:${bob.user_id}`, // Bob Térry `dm:${ted.user_id}`, // Ted Smith `dm:${inaccessible_user.user_id}`, // Test unknown user `sender:${bob.user_id}`, `sender:${ted.user_id}`, `sender:${inaccessible_user.user_id}`, `dm-including:${bob.user_id}`, `dm-including:${ted.user_id}`, `dm-including:${inaccessible_user.user_id}`, `mentions:${bob.user_id}`, `mentions:${ted.user_id}`, `mentions:${inaccessible_user.user_id}`, ]; assert.deepEqual(suggestions, expected); suggestions = get_suggestions("Ted "); // note space expected = [ "Ted", `dm:${ted.user_id}`, `sender:${ted.user_id}`, `dm-including:${ted.user_id}`, `mentions:${ted.user_id}`, ]; assert.deepEqual(suggestions, expected); query = "sender:ted sm"; expected = [`sender:${ted.user_id}`]; suggestions = get_suggestions(query); assert.deepEqual(suggestions, expected); // The search bar typeahead always converts person operators to // user_id-based pills, so raw email operands are not reachable. // Both valid and invalid emails get merged with the trailing // search term, producing no person match. query = "sender:ted@zulip.com new"; expected = []; suggestions = get_suggestions(query); assert.deepEqual(suggestions, expected); query = "sender:ted@tulip.com new"; expected = []; suggestions = get_suggestions(query); assert.deepEqual(suggestions, expected); }); test("operator_suggestions", ({override}) => { override(stream_topic_history_util, "get_server_history", noop); override(narrow_state, "stream_id", () => undefined); // Completed operator should return nothing let query = "channel:"; let suggestions = get_suggestions(query); let expected = []; assert.deepEqual(suggestions, expected); query = "ch"; suggestions = get_suggestions(query); expected = ["ch", "channels:", "channel:"]; assert.deepEqual(suggestions, expected); query = "-s"; suggestions = get_suggestions(query); expected = ["-s", "-sender:", "-channels:", "-channel:", `-sender:${me.user_id}`]; assert.deepEqual(suggestions, expected); stream_data.add_sub_for_tests( make_stream({ stream_id: 66, name: "misc", subscribed: true, }), ); query = "channel:66 is:alerted -f"; suggestions = get_suggestions(query); expected = [ "channel:66 is:alerted -f", "channel:66 is:alerted -sender:", `channel:66 is:alerted -sender:${me.user_id}`, ]; assert.deepEqual(suggestions, expected); }); test("queries_with_spaces", () => { const office_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: office_id, name: "office", subscribed: true, }), ); const dev_help_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: dev_help_id, name: "dev help", subscribed: true, }), ); // test allowing spaces with quotes surrounding operand let query = 'channel:"dev he"'; let suggestions = get_suggestions(query); let expected = [`channel:${dev_help_id}`]; assert.deepEqual(suggestions, expected); // test mismatched quote query = 'channel:"dev h'; suggestions = get_suggestions(query); expected = [`channel:${dev_help_id}`]; assert.deepEqual(suggestions, expected); }); test("empty query suggestions in an inaccessible narrow", ({override}) => { const office_id = new_stream_id(); stream_data.add_sub_for_tests( make_stream({ stream_id: office_id, name: "office", subscribed: true, }), ); const unknown_stream_id = new_stream_id(); let narrow_terms; override(narrow_state, "search_terms", () => narrow_terms); override(narrow_state, "narrowed_by_topic_reply", () => false); override(narrow_state, "narrowed_by_pm_reply", () => false); // An empty query in an accessible narrow suggests searching the // current conversation. narrow_terms = [ {operator: "channel", operand: office_id.toString(), negated: false}, {operator: "topic", operand: "lunch", negated: false}, ]; let suggestions = search.get_suggestions([], [], true); assert.equal(suggestions[0], `channel:${office_id} topic:lunch`); // When the narrow's channel is unknown to this client, // we don't suggest searching the current conversation, and the // suggestions are the same as if there were no current filter. narrow_terms = [ {operator: "channel", operand: unknown_stream_id.toString(), negated: false}, {operator: "topic", operand: "lunch", negated: false}, ]; suggestions = search.get_suggestions([], [], true); assert.deepEqual(suggestions, search.get_suggestions([], [], false)); });