/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

/* import-globals-from ../../unit/head.js */
/* eslint-disable jsdoc/require-param */

ChromeUtils.defineESModuleGetters(this, {
  QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
  TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
  UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs",
  UrlbarProviderQuickSuggest:
    "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
  UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs",
});

add_setup(async function setUpQuickSuggestXpcshellTest() {
  // Initializing TelemetryEnvironment in an xpcshell environment requires
  // jumping through a bunch of hoops. Suggest's use of TelemetryEnvironment is
  // tested in browser tests, and there's no other necessary reason to wait for
  // TelemetryEnvironment initialization in xpcshell tests, so just skip it.
  QuickSuggest._testSkipTelemetryEnvironmentInit = true;
});

/**
 * Tests quick suggest prefs migrations.
 *
 * @param {object} options
 *   The options object.
 * @param {object} options.testOverrides
 *   An object that modifies how migration is performed. It has the following
 *   properties, and all are optional:
 *
 *   {number} migrationVersion
 *     Migration will stop at this version, so for example you can test
 *     migration only up to version 1 even when the current actual version is
 *     larger than 1.
 *   {object} defaultPrefs
 *     An object that maps pref names (relative to `browser.urlbar`) to
 *     default-branch values. These should be the default prefs for the given
 *     `migrationVersion` and will be set as defaults before migration occurs.
 *
 * @param {object} options.expectedPrefs
 *   The expected prefs after migration: `{ defaultBranch, userBranch }`
 *   Pref names should be relative to `browser.urlbar`.
 * @param {boolean} options.shouldEnable
 *   Whether Suggest should be enabled when migration occurs.
 * @param {object} [options.initialUserBranch]
 *   Prefs to set on the user branch before migration ocurs. Use these to
 *   simulate user actions like disabling prefs or opting in or out of the
 *   online modal. Pref names should be relative to `browser.urlbar`.
 */
async function doMigrateTest({
  testOverrides,
  expectedPrefs,
  shouldEnable = true,
  initialUserBranch = {},
}) {
  info(
    "Testing migration: " +
      JSON.stringify({
        testOverrides,
        initialUserBranch,
        shouldEnable,
        expectedPrefs,
      })
  );

  function setPref(branch, name, value) {
    switch (typeof value) {
      case "boolean":
        branch.setBoolPref(name, value);
        break;
      case "number":
        branch.setIntPref(name, value);
        break;
      case "string":
        branch.setCharPref(name, value);
        break;
      default:
        Assert.ok(
          false,
          `Pref type not handled for setPref: ${name} = ${value}`
        );
        break;
    }
  }

  function getPref(branch, name) {
    let type = typeof UrlbarPrefs.get(name);
    switch (type) {
      case "boolean":
        return branch.getBoolPref(name);
      case "number":
        return branch.getIntPref(name);
      case "string":
        return branch.getCharPref(name);
      default:
        Assert.ok(false, `Pref type not handled for getPref: ${name} ${type}`);
        break;
    }
    return null;
  }

  let defaultBranch = Services.prefs.getDefaultBranch("browser.urlbar.");
  let userBranch = Services.prefs.getBranch("browser.urlbar.");

  // Set initial prefs. `initialDefaultBranch` are firefox.js values, i.e.,
  // defaults immediately after startup, before Suggest init and migration.
  UrlbarPrefs.clear("quicksuggest.migrationVersion");
  let initialDefaultBranch = {
    "suggest.quicksuggest.nonsponsored": false,
    "suggest.quicksuggest.sponsored": false,
    "quicksuggest.dataCollection.enabled": false,
  };
  for (let name of Object.keys(initialDefaultBranch)) {
    userBranch.clearUserPref(name);
  }
  for (let [branch, prefs] of [
    [defaultBranch, initialDefaultBranch],
    [userBranch, initialUserBranch],
  ]) {
    for (let [name, value] of Object.entries(prefs)) {
      if (value !== undefined) {
        setPref(branch, name, value);
      }
    }
  }

  // Reinitialize Suggest and check prefs twice. The first time the migration
  // should happen, and the second time the migration should not happen and
  // all the prefs should stay the same.
  for (let i = 0; i < 2; i++) {
    info(`Reinitializing Suggest, i=${i}`);

    // Reinitialize Suggest.
    await QuickSuggest._test_reinit({
      ...testOverrides,
      region: shouldEnable ? "US" : "XX",
      locale: shouldEnable ? "en-US" : "xx-XX",
    });

    // Check expected pref values. Store expected effective values as we go so
    // we can check them afterward. For a given pref, the expected effective
    // value is the user value, or if there's not a user value, the default
    // value.
    let expectedEffectivePrefs = {};
    let {
      defaultBranch: expectedDefaultBranch,
      userBranch: expectedUserBranch,
    } = expectedPrefs;
    expectedDefaultBranch = expectedDefaultBranch || {};
    expectedUserBranch = expectedUserBranch || {};
    for (let [branch, prefs, branchType] of [
      [defaultBranch, expectedDefaultBranch, "default"],
      [userBranch, expectedUserBranch, "user"],
    ]) {
      let entries = Object.entries(prefs);
      if (!entries.length) {
        continue;
      }

      info(
        `Checking expected prefs on ${branchType} branch after Suggest init`
      );
      for (let [name, value] of entries) {
        expectedEffectivePrefs[name] = value;
        if (branch == userBranch) {
          Assert.ok(
            userBranch.prefHasUserValue(name),
            `Pref ${name} is on user branch`
          );
        }
        Assert.equal(
          getPref(branch, name),
          value,
          `Pref ${name} value on ${branchType} branch`
        );
      }
    }

    info(
      `Making sure prefs on the default branch without expected user-branch values are not on the user branch`
    );
    for (let name of Object.keys(initialDefaultBranch)) {
      if (!expectedUserBranch.hasOwnProperty(name)) {
        Assert.ok(
          !userBranch.prefHasUserValue(name),
          `Pref ${name} is not on user branch`
        );
      }
    }

    info(`Checking expected effective prefs`);
    for (let [name, value] of Object.entries(expectedEffectivePrefs)) {
      Assert.equal(
        UrlbarPrefs.get(name),
        value,
        `Pref ${name} effective value`
      );
    }

    let currentVersion =
      testOverrides?.migrationVersion === undefined
        ? QuickSuggest.MIGRATION_VERSION
        : testOverrides.migrationVersion;
    Assert.equal(
      UrlbarPrefs.get("quicksuggest.migrationVersion"),
      currentVersion,
      "quicksuggest.migrationVersion is correct after migration"
    );
  }

  // Clean up.
  UrlbarPrefs.clear("quicksuggest.migrationVersion");
  let userBranchNames = [
    ...Object.keys(initialUserBranch),
    ...Object.keys(expectedPrefs.userBranch || {}),
  ];
  for (let name of userBranchNames) {
    userBranch.clearUserPref(name);
  }
}

/**
 * Does a test that dismisses a single result by triggering a command on it.
 *
 * @param {object} options
 *   Options object.
 * @param {SuggestFeature} options.feature
 *   The feature that provides the dismissed result.
 * @param {UrlbarResult} options.result
 *   The result to trigger the command on.
 * @param {string} options.command
 *   The name of the command to trigger. It should dismiss one result.
 * @param {Array} options.queriesForDismissals
 *   Array of objects: `{ query, expectedResults }`
 *   For each object, the test will perform a search with `query` as the search
 *   string. After dismissing the result, the query shouldn't match any results.
 *   After clearing dismissals, the query should match the results in
 *   `expectedResults`. If `expectedResults` is omitted, `[result]` will be
 *   used.
 * @param {Array} options.queriesForOthers
 *   Array of objects: `{ query, expectedResults }`
 *   For each object, the test will perform a search with `query` as the search
 *   string. The query should always match `expectedResults`.
 */
async function doDismissOneTest({
  feature,
  result,
  command,
  queriesForDismissals,
  queriesForOthers,
}) {
  await QuickSuggest.clearDismissedSuggestions();
  await QuickSuggestTestUtils.forceSync();
  Assert.ok(
    !(await QuickSuggest.canClearDismissedSuggestions()),
    "Sanity check: canClearDismissedSuggestions should return false initially"
  );

  let changedPromise = TestUtils.topicObserved(
    "quicksuggest-dismissals-changed"
  );

  triggerCommand({
    result,
    command,
    feature,
    expectedCountsByCall: {
      removeResult: 1,
    },
  });

  info("Awaiting dismissals-changed promise");
  await changedPromise;

  Assert.ok(
    await QuickSuggest.canClearDismissedSuggestions(),
    "canClearDismissedSuggestions should return true after triggering command"
  );
  Assert.ok(
    await QuickSuggest.isResultDismissed(result),
    "The result should be dismissed"
  );

  for (let { query } of queriesForDismissals) {
    info("Doing search for dismissed suggestions: " + JSON.stringify(query));
    await check_results({
      context: createContext(query, {
        providers: [UrlbarProviderQuickSuggest.name],
        isPrivate: false,
      }),
      matches: [],
    });
  }

  for (let { query, expectedResults } of queriesForOthers) {
    info(
      "Doing search for non-dismissed suggestions: " + JSON.stringify(query)
    );
    await check_results({
      context: createContext(query, {
        providers: [UrlbarProviderQuickSuggest.name],
        isPrivate: false,
      }),
      matches: expectedResults,
    });
  }

  let clearedPromise = TestUtils.topicObserved(
    "quicksuggest-dismissals-cleared"
  );

  info("Clearing dismissals");
  await QuickSuggest.clearDismissedSuggestions();

  // It's not necessary to await this -- awaiting `clearDismissedSuggestions()`
  // is sufficient -- but we do it to make sure the notification is sent.
  info("Awaiting dismissals-cleared promise");
  await clearedPromise;

  Assert.ok(
    !(await QuickSuggest.canClearDismissedSuggestions()),
    "canClearDismissedSuggestions should return false after clearing dismissals"
  );

  for (let { query, expectedResults = [result] } of queriesForDismissals) {
    info("Doing search after clearing dismissals: " + JSON.stringify(query));
    await check_results({
      context: createContext(query, {
        providers: [UrlbarProviderQuickSuggest.name],
        isPrivate: false,
      }),
      matches: expectedResults,
    });
  }
}

/**
 * Does a test that dismisses a suggestion type (i.e., all suggestions of a
 * certain type) by triggering a command on a result.
 *
 * @param {object} options
 *   Options object.
 * @param {SuggestFeature} options.feature
 *   The feature that provides the suggestion type.
 * @param {UrlbarResult} options.result
 *   The result to trigger the command on.
 * @param {string} options.command
 *   The name of the command to trigger. It should dismiss all results of a
 *   suggestion type.
 * @param {string} options.pref
 *   The name of the user-controlled pref (relative to `browser.urlbar.`) that
 *   controls the suggestion type. Should be the same as
 *   `feature.primaryUserControlledPreference`.
 * @param {Array} options.queries
 *   Array of objects: `{ query, expectedResults }`
 *   For each object, the test will perform a search with `query` as the search
 *   string. After dismissing the suggestion type, the query shouldn't match any
 *   results. After clearing dismissals, the query should match the results in
 *   `expectedResults`. If `expectedResults` is omitted, `[result]` will be
 *   used.
 */
async function doDismissAllTest({ feature, result, command, pref, queries }) {
  await QuickSuggest.clearDismissedSuggestions();
  await QuickSuggestTestUtils.forceSync();
  Assert.ok(
    !(await QuickSuggest.canClearDismissedSuggestions()),
    "Sanity check: canClearDismissedSuggestions should return false initially"
  );

  let changedPromise = TestUtils.topicObserved(
    "quicksuggest-dismissals-changed"
  );

  triggerCommand({
    result,
    command,
    feature,
    expectedCountsByCall: {
      removeResult: 1,
    },
  });

  info("Awaiting dismissals-changed promise");
  await changedPromise;

  Assert.ok(
    await QuickSuggest.canClearDismissedSuggestions(),
    "canClearDismissedSuggestions should return true after triggering command"
  );
  Assert.ok(
    !UrlbarPrefs.get(pref),
    "Pref should be false after triggering command: " + pref
  );

  for (let { query } of queries) {
    info("Doing search after triggering command: " + JSON.stringify(query));
    await check_results({
      context: createContext(query, {
        providers: [UrlbarProviderQuickSuggest.name],
        isPrivate: false,
      }),
      matches: [],
    });
  }

  let clearedPromise = TestUtils.topicObserved(
    "quicksuggest-dismissals-cleared"
  );

  info("Clearing dismissals");
  await QuickSuggest.clearDismissedSuggestions();

  // It's not necessary to await this -- awaiting `clearDismissedSuggestions()`
  // is sufficient -- but we do it to make sure the notification is sent.
  info("Awaiting dismissals-cleared promise");
  await clearedPromise;

  Assert.ok(
    !(await QuickSuggest.canClearDismissedSuggestions()),
    "canClearDismissedSuggestions should return false after clearing dismissals"
  );
  Assert.ok(
    UrlbarPrefs.get(pref),
    "Pref should be true after clearing it: " + pref
  );

  // Clearing the pref will trigger a sync, so wait for it.
  await QuickSuggestTestUtils.forceSync();

  for (let { query, expectedResults = [result] } of queries) {
    info("Doing search after clearing dismissals: " + JSON.stringify(query));
    await check_results({
      context: createContext(query, {
        providers: [UrlbarProviderQuickSuggest.name],
        isPrivate: false,
      }),
      matches: expectedResults,
    });
  }
}

/**
 * Does some "show less frequently" tests where the cap is set in remote
 * settings and Nimbus. See `doOneShowLessFrequentlyTest()`. This function
 * assumes the matching behavior implemented by the given `SuggestFeature` is
 * based on matching prefixes of the given keyword starting at the first word.
 * It also assumes the `SuggestFeature` provides suggestions in remote settings.
 *
 * @param {object} options
 *   Options object.
 * @param {SuggestFeature} options.feature
 *   The feature that provides the suggestion matched by the searches.
 * @param {*} options.expectedResult
 *   The expected result that should be matched, for searches that are expected
 *   to match a result. Can also be a function; it's passed the current search
 *   string and it should return the expected result.
 * @param {string} options.showLessFrequentlyCountPref
 *   The name of the pref that stores the "show less frequently" count being
 *   tested.
 * @param {string} options.nimbusCapVariable
 *   The name of the Nimbus variable that stores the "show less frequently" cap
 *   being tested.
 * @param {object} options.keyword
 *   The primary keyword to use during the test.
 * @param {number} options.keywordBaseIndex
 *   The index in `keyword` to base substring checks around. This function will
 *   test substrings starting at the beginning of keyword and ending at the
 *   following indexes: one index before `keywordBaseIndex`,
 *   `keywordBaseIndex`, `keywordBaseIndex` + 1, `keywordBaseIndex` + 2, and
 *   `keywordBaseIndex` + 3.
 */
async function doShowLessFrequentlyTests({
  feature,
  expectedResult,
  showLessFrequentlyCountPref,
  nimbusCapVariable,
  keyword,
  keywordBaseIndex = keyword.indexOf(" "),
}) {
  // Do some sanity checks on the keyword. Any checks that fail are errors in
  // the test.
  if (keywordBaseIndex <= 0) {
    throw new Error(
      "keywordBaseIndex must be > 0, but it's " + keywordBaseIndex
    );
  }
  if (keyword.length < keywordBaseIndex + 3) {
    throw new Error(
      "keyword must have at least two chars after keywordBaseIndex"
    );
  }

  let tests = [
    {
      showLessFrequentlyCount: 0,
      canShowLessFrequently: true,
      newSearches: {
        [keyword.substring(0, keywordBaseIndex - 1)]: false,
        [keyword.substring(0, keywordBaseIndex)]: true,
        [keyword.substring(0, keywordBaseIndex + 1)]: true,
        [keyword.substring(0, keywordBaseIndex + 2)]: true,
        [keyword.substring(0, keywordBaseIndex + 3)]: true,
      },
    },
    {
      showLessFrequentlyCount: 1,
      canShowLessFrequently: true,
      newSearches: {
        [keyword.substring(0, keywordBaseIndex)]: false,
      },
    },
    {
      showLessFrequentlyCount: 2,
      canShowLessFrequently: true,
      newSearches: {
        [keyword.substring(0, keywordBaseIndex + 1)]: false,
      },
    },
    {
      showLessFrequentlyCount: 3,
      canShowLessFrequently: false,
      newSearches: {
        [keyword.substring(0, keywordBaseIndex + 2)]: false,
      },
    },
    {
      showLessFrequentlyCount: 3,
      canShowLessFrequently: false,
      newSearches: {},
    },
  ];

  info("Testing 'show less frequently' with cap in remote settings");
  await doOneShowLessFrequentlyTest({
    tests,
    feature,
    expectedResult,
    showLessFrequentlyCountPref,
    rs: {
      show_less_frequently_cap: 3,
    },
  });

  // Nimbus should override remote settings.
  info("Testing 'show less frequently' with cap in Nimbus and remote settings");
  await doOneShowLessFrequentlyTest({
    tests,
    feature,
    expectedResult,
    showLessFrequentlyCountPref,
    rs: {
      show_less_frequently_cap: 10,
    },
    nimbus: {
      [nimbusCapVariable]: 3,
    },
  });
}

/**
 * Does a group of searches, increments the "show less frequently" count, and
 * repeats until all groups are done. The cap can be set by remote settings
 * config and/or Nimbus.
 *
 * @param {object} options
 *   Options object.
 * @param {SuggestFeature} options.feature
 *   The feature that provides the suggestion matched by the searches.
 * @param {*} options.expectedResult
 *   The expected result that should be matched, for searches that are expected
 *   to match a result. Can also be a function; it's passed the current search
 *   string and it should return the expected result.
 * @param {string} options.showLessFrequentlyCountPref
 *   The name of the pref that stores the "show less frequently" count being
 *   tested.
 * @param {object} options.tests
 *   An array where each item describes a group of new searches to perform and
 *   expected state. Each item should look like this:
 *   `{ showLessFrequentlyCount, canShowLessFrequently, newSearches }`
 *
 *   {number} showLessFrequentlyCount
 *     The expected value of `showLessFrequentlyCount` before the group of
 *     searches is performed.
 *   {boolean} canShowLessFrequently
 *     The expected value of `canShowLessFrequently` before the group of
 *     searches is performed.
 *   {object} newSearches
 *     An object that maps each search string to a boolean that indicates
 *     whether the first remote settings suggestion should be triggered by the
 *     search string. Searches are cumulative: The intended use is to pass a
 *     large initial group of searches in the first search group, and then each
 *     following `newSearches` is a diff against the previous.
 * @param {object} options.rs
 *   The remote settings config to set.
 * @param {object} options.nimbus
 *   The Nimbus variables to set.
 */
async function doOneShowLessFrequentlyTest({
  feature,
  expectedResult,
  showLessFrequentlyCountPref,
  tests,
  rs = {},
  nimbus = {},
}) {
  // Disable Merino so we trigger only remote settings suggestions. The
  // `SuggestFeature` is expected to add remote settings suggestions using
  // keywords start starting with the first word in each full keyword, but the
  // mock Merino server will always return whatever suggestion it's told to
  // return regardless of the search string. That means Merino will return a
  // suggestion for a keyword that's smaller than the first full word.
  UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false);

  // Set Nimbus variables and RS config.
  let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(nimbus);
  await QuickSuggestTestUtils.withConfig({
    config: rs,
    callback: async () => {
      let cumulativeSearches = {};

      for (let {
        showLessFrequentlyCount,
        canShowLessFrequently,
        newSearches,
      } of tests) {
        info(
          "Starting subtest: " +
            JSON.stringify({
              showLessFrequentlyCount,
              canShowLessFrequently,
              newSearches,
            })
        );

        Assert.equal(
          feature.showLessFrequentlyCount,
          showLessFrequentlyCount,
          "showLessFrequentlyCount should be correct initially"
        );
        Assert.equal(
          UrlbarPrefs.get(showLessFrequentlyCountPref),
          showLessFrequentlyCount,
          "Pref should be correct initially"
        );
        Assert.equal(
          feature.canShowLessFrequently,
          canShowLessFrequently,
          "canShowLessFrequently should be correct initially"
        );

        // Merge the current `newSearches` object into the cumulative object.
        cumulativeSearches = {
          ...cumulativeSearches,
          ...newSearches,
        };

        for (let [searchString, isExpected] of Object.entries(
          cumulativeSearches
        )) {
          info("Doing search: " + JSON.stringify({ searchString, isExpected }));

          let results = [];
          if (isExpected) {
            results.push(
              typeof expectedResult == "function"
                ? expectedResult(searchString)
                : expectedResult
            );
          }

          await check_results({
            context: createContext(searchString, {
              providers: [UrlbarProviderQuickSuggest.name],
              isPrivate: false,
            }),
            matches: results,
          });
        }

        feature.incrementShowLessFrequentlyCount();
      }
    },
  });

  await cleanUpNimbus();
  UrlbarPrefs.clear(showLessFrequentlyCountPref);
  UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true);
}

/**
 * Queries the Rust component directly and checks the returned suggestions. The
 * point is to make sure the Rust backend passes the correct providers to the
 * Rust component depending on the types of enabled suggestions. Assuming the
 * Rust component isn't buggy, it should return suggestions only for the
 * passed-in providers.
 *
 * @param {object} options
 *   Options object
 * @param {string} options.searchString
 *   The search string.
 * @param {Array} options.tests
 *   Array of test objects: `{ prefs, expectedUrls }`
 *
 *   For each object, the given prefs are set, the Rust component is queried
 *   using the given search string, and the URLs of the returned suggestions are
 *   compared to the given expected URLs (order doesn't matter).
 *
 *   {object} prefs
 *     An object mapping pref names (relative to `browser.urlbar`) to values.
 *     These prefs will be set before querying and should be used to enable or
 *     disable particular types of suggestions.
 *   {Array} expectedUrls
 *     An array of the URLs of the suggestions that are expected to be returned.
 *     The order doesn't matter.
 */
async function doRustProvidersTests({ searchString, tests }) {
  for (let { prefs, expectedUrls } of tests) {
    info(
      "Starting Rust providers test: " + JSON.stringify({ prefs, expectedUrls })
    );

    info("Setting prefs and forcing sync");
    for (let [name, value] of Object.entries(prefs)) {
      UrlbarPrefs.set(name, value);
    }
    await QuickSuggestTestUtils.forceSync();

    info("Querying with search string: " + JSON.stringify(searchString));
    let suggestions = await QuickSuggest.rustBackend.query(searchString);
    info("Got suggestions: " + JSON.stringify(suggestions));

    Assert.deepEqual(
      suggestions.map(s => s.url).sort(),
      expectedUrls.sort(),
      "query() should return the expected suggestions (by URL)"
    );

    info("Clearing prefs and forcing sync");
    for (let name of Object.keys(prefs)) {
      UrlbarPrefs.clear(name);
    }
    await QuickSuggestTestUtils.forceSync();
  }
}

/**
 * Simulates performing a command for a feature by calling its `onEngagement()`.
 *
 * @param {object} options
 *   Options object.
 * @param {SuggestFeature} options.feature
 *   The feature whose command will be triggered.
 * @param {string} options.command
 *   The name of the command to trigger.
 * @param {UrlbarResult} options.result
 *   The result that the command will act on.
 * @param {string} options.searchString
 *   The search string to pass to `onEngagement()`.
 * @param {object} options.expectedCountsByCall
 *   If non-null, this should map controller and view method names to the number
 *   of times they should be called in response to the command.
 * @returns {Map}
 *   A map from names of methods on the controller and view to the number of
 *   times they were called.
 */
function triggerCommand({
  feature,
  command,
  result,
  searchString = "",
  expectedCountsByCall = null,
}) {
  info(`Calling ${feature.name}.onEngagement() to trigger command: ${command}`);

  let countsByCall = new Map();
  let addCall = name => {
    if (!countsByCall.has(name)) {
      countsByCall.set(name, 0);
    }
    countsByCall.set(name, countsByCall.get(name) + 1);
  };

  feature.onEngagement(
    // query context
    {},
    // controller
    {
      removeResult() {
        addCall("removeResult");
      },
      view: {
        acknowledgeFeedback() {
          addCall("acknowledgeFeedback");
        },
        invalidateResultMenuCommands() {
          addCall("invalidateResultMenuCommands");
        },
      },
    },
    // details
    { result, selType: command },
    searchString
  );

  if (expectedCountsByCall) {
    for (let [name, expectedCount] of Object.entries(expectedCountsByCall)) {
      Assert.equal(
        countsByCall.get(name) ?? 0,
        expectedCount,
        "Function should have been called the expected number of times: " + name
      );
    }
  }

  return countsByCall;
}
