com.android.quicksearchbox.ShortcutRepositoryTest.java Source code

Java tutorial

Introduction

Here is the source code for com.android.quicksearchbox.ShortcutRepositoryTest.java

Source

/*
 * Copyright (C) 2009 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.quicksearchbox;

import com.android.quicksearchbox.util.MockExecutor;
import com.android.quicksearchbox.util.Util;

import org.json.JSONArray;

import android.app.SearchManager;
import android.content.Intent;
import android.test.AndroidTestCase;
import android.test.MoreAsserts;
import android.test.suitebuilder.annotation.MediumTest;
import android.util.Log;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

/**
 * Abstract base class for tests of  {@link ShortcutRepository}
 * implementations.  Most importantly, verifies the
 * stuff we are doing with sqlite works how we expect it to.
 *
 * Attempts to test logic independent of the (sql) details of the implementation, so these should
 * be useful even in the face of a schema change.
 */
@MediumTest
public class ShortcutRepositoryTest extends AndroidTestCase {

    private static final String TAG = "ShortcutRepositoryTest";

    static final long NOW = 1239841162000L; // millis since epoch. some time in 2009

    static final Source APP_SOURCE = new MockSource("com.example.app/.App");

    static final Source APP_SOURCE_V2 = new MockSource("com.example.app/.App", 2);

    static final Source CONTACTS_SOURCE = new MockSource("com.android.contacts/.Contacts");

    static final Source BOOKMARKS_SOURCE = new MockSource("com.android.browser/.Bookmarks");

    static final Source HISTORY_SOURCE = new MockSource("com.android.browser/.History");

    static final Source MUSIC_SOURCE = new MockSource("com.android.music/.Music");

    static final Source MARKET_SOURCE = new MockSource("com.android.vending/.Market");

    static final Corpus APP_CORPUS = new MockCorpus(APP_SOURCE);

    static final Corpus CONTACTS_CORPUS = new MockCorpus(CONTACTS_SOURCE);

    static final Corpus WEB_CORPUS = new MockCorpus(MockSource.WEB_SOURCE);

    static final int MAX_SHORTCUTS = 8;

    protected Config mConfig;
    protected MockCorpora mCorpora;
    protected MockExecutor mLogExecutor;
    protected ShortcutRefresher mRefresher;

    protected List<Corpus> mAllowedCorpora;

    protected ShortcutRepositoryImplLog mRepo;

    protected ListSuggestionCursor mAppSuggestions;
    protected ListSuggestionCursor mContactSuggestions;

    protected SuggestionData mApp1;
    protected SuggestionData mApp2;
    protected SuggestionData mApp3;

    protected SuggestionData mContact1;
    protected SuggestionData mContact2;

    protected SuggestionData mWeb1;

    protected ShortcutRepositoryImplLog createShortcutRepository() {
        return new ShortcutRepositoryImplLog(getContext(), mConfig, mCorpora, mRefresher, new MockHandler(),
                mLogExecutor, "test-shortcuts-log.db");
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        mConfig = new Config(getContext());
        mCorpora = new MockCorpora();
        mCorpora.addCorpus(APP_CORPUS);
        mCorpora.addCorpus(CONTACTS_CORPUS);
        mCorpora.addCorpus(WEB_CORPUS);
        mRefresher = new MockShortcutRefresher();
        mLogExecutor = new MockExecutor();
        mRepo = createShortcutRepository();

        mAllowedCorpora = new ArrayList<Corpus>(mCorpora.getAllCorpora());

        mApp1 = makeApp("app1");
        mApp2 = makeApp("app2");
        mApp3 = makeApp("app3");
        mAppSuggestions = new ListSuggestionCursor("foo", mApp1, mApp2, mApp3);

        mContact1 = new SuggestionData(CONTACTS_SOURCE).setText1("Joe Blow").setIntentAction("view")
                .setIntentData("contacts/joeblow").setShortcutId("j-blow");
        mContact2 = new SuggestionData(CONTACTS_SOURCE).setText1("Mike Johnston").setIntentAction("view")
                .setIntentData("contacts/mikeJ").setShortcutId("mo-jo");

        mWeb1 = new SuggestionData(MockSource.WEB_SOURCE).setText1("foo").setIntentAction(Intent.ACTION_WEB_SEARCH)
                .setSuggestionQuery("foo");

        mContactSuggestions = new ListSuggestionCursor("foo", mContact1, mContact2);
    }

    private SuggestionData makeApp(String name) {
        return new SuggestionData(APP_SOURCE).setText1(name).setIntentAction("view").setIntentData("apps/" + name)
                .setShortcutId("shorcut_" + name);
    }

    private SuggestionData makeContact(String name) {
        return new SuggestionData(CONTACTS_SOURCE).setText1(name).setIntentAction("view")
                .setIntentData("contacts/" + name).setShortcutId("shorcut_" + name);
    }

    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
        mRepo.deleteRepository();
    }

    public void testHasHistory() {
        assertHasHistory(false);
        reportClickAtTime(mAppSuggestions, 0, NOW);
        assertHasHistory(true);
        mRepo.clearHistory();
        mLogExecutor.runNext();
        assertHasHistory(false);
    }

    public void testRemoveFromHistory() {
        SuggestionData john = new SuggestionData(CONTACTS_SOURCE).setText1("john doe").setIntentAction("view")
                .setIntentData("john_doe");
        SuggestionData jane = new SuggestionData(CONTACTS_SOURCE).setText1("jane doe").setIntentAction("view")
                .setIntentData("jane_doe");
        reportClick("j", john);
        reportClick("j", john);
        reportClick("j", jane);
        assertShortcuts("j", john, jane);
        removeFromHistory(new ListSuggestionCursor("j", jane, john), 1);
        assertShortcuts("j", jane);
    }

    public void testRemoveFromHistoryNonExisting() {
        SuggestionData john = new SuggestionData(CONTACTS_SOURCE).setText1("john doe").setIntentAction("view")
                .setIntentData("john_doe");
        SuggestionData jane = new SuggestionData(CONTACTS_SOURCE).setText1("jane doe").setIntentAction("view")
                .setIntentData("jane_doe");
        reportClick("j", john);
        assertShortcuts("j", john);
        removeFromHistory(new ListSuggestionCursor("j", jane), 0);
        assertShortcuts("j", john);
    }

    public void testNoMatch() {
        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE).setText1("bob smith").setIntentAction("action")
                .setIntentData("data");

        reportClick("bob smith", clicked);
        assertNoShortcuts("joe");
    }

    public void testFullPackingUnpacking() {
        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE).setFormat("<i>%s</i>").setText1("title")
                .setText2("description").setText2Url("description_url")
                .setIcon1("android.resource://system/drawable/foo").setIcon2("content://test/bar")
                .setIntentAction("action").setIntentData("data").setSuggestionQuery("query")
                .setIntentExtraData("extradata").setShortcutId("idofshortcut").setSuggestionLogType("logtype");
        reportClick("q", clicked);

        assertShortcuts("q", clicked);
        assertShortcuts("", clicked);
    }

    public void testSpinnerWhileRefreshing() {
        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE).setText1("title").setText2("description")
                .setIcon2("icon2").setSuggestionQuery("query").setIntentExtraData("extradata")
                .setShortcutId("idofshortcut").setSpinnerWhileRefreshing(true);

        reportClick("q", clicked);

        String spinnerUri = Util.getResourceUri(mContext, R.drawable.search_spinner).toString();
        SuggestionData expected = new SuggestionData(CONTACTS_SOURCE).setText1("title").setText2("description")
                .setIcon2(spinnerUri).setSuggestionQuery("query").setIntentExtraData("extradata")
                .setShortcutId("idofshortcut").setSpinnerWhileRefreshing(true);

        assertShortcuts("q", expected);
    }

    public void testPrefixesMatch() {
        assertNoShortcuts("bob");

        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE).setText1("bob smith the third")
                .setIntentAction("action").setIntentData("intentdata");

        reportClick("bob smith", clicked);

        assertShortcuts("bob smith", clicked);
        assertShortcuts("bob s", clicked);
        assertShortcuts("b", clicked);
    }

    public void testMatchesOneAndNotOthers() {
        SuggestionData bob = new SuggestionData(CONTACTS_SOURCE).setText1("bob smith the third")
                .setIntentAction("action").setIntentData("intentdata/bob");

        reportClick("bob", bob);

        SuggestionData george = new SuggestionData(CONTACTS_SOURCE).setText1("george jones")
                .setIntentAction("action").setIntentData("intentdata/george");
        reportClick("geor", george);

        assertShortcuts("b for bob", "b", bob);
        assertShortcuts("g for george", "g", george);
    }

    public void testDifferentPrefixesMatchSameEntity() {
        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE).setText1("bob smith the third")
                .setIntentAction("action").setIntentData("intentdata");

        reportClick("bob", clicked);
        reportClick("smith", clicked);
        assertShortcuts("b", clicked);
        assertShortcuts("s", clicked);
    }

    public void testMoreClicksWins() {
        reportClick("app", mApp1);
        reportClick("app", mApp2);
        reportClick("app", mApp1);

        assertShortcuts("expected app1 to beat app2 since it has more hits", "app", mApp1, mApp2);

        reportClick("app", mApp2);
        reportClick("app", mApp2);

        assertShortcuts("query 'app': expecting app2 to beat app1 since it has more hits", "app", mApp2, mApp1);
        assertShortcuts("query 'a': expecting app2 to beat app1 since it has more hits", "a", mApp2, mApp1);
    }

    public void testMostRecentClickWins() {
        // App 1 has 3 clicks
        reportClick("app", mApp1, NOW - 5);
        reportClick("app", mApp1, NOW - 5);
        reportClick("app", mApp1, NOW - 5);
        // App 2 has 2 clicks
        reportClick("app", mApp2, NOW - 2);
        reportClick("app", mApp2, NOW - 2);
        // App 3 only has 1, but it's most recent
        reportClick("app", mApp3, NOW - 1);

        assertShortcuts("expected app3 to beat app1 and app2 because it's clicked last", "app", mApp3, mApp1,
                mApp2);

        reportClick("app", mApp2, NOW);

        assertShortcuts("query 'app': expecting app2 to beat app1 since it's clicked last", "app", mApp2, mApp1,
                mApp3);
        assertShortcuts("query 'a': expecting app2 to beat app1 since it's clicked last", "a", mApp2, mApp1, mApp3);
        assertShortcuts("query '': expecting app2 to beat app1 since it's clicked last", "", mApp2, mApp1, mApp3);
    }

    public void testMostRecentClickWinsOnEmptyQuery() {
        reportClick("app", mApp1, NOW - 3);
        reportClick("app", mApp1, NOW - 2);
        reportClick("app", mApp2, NOW - 1);

        assertShortcuts("expected app2 to beat app1 since it's clicked last", "", mApp2, mApp1);
    }

    public void testMostRecentClickWinsEvenWithMoreThanLimitShortcuts() {
        for (int i = 0; i < MAX_SHORTCUTS; i++) {
            SuggestionData app = makeApp("TestApp" + i);
            // Each of these shortcuts has two clicks
            reportClick("app", app, NOW - 2);
            reportClick("app", app, NOW - 1);
        }

        // mApp1 has only one click, but is more recent
        reportClick("app", mApp1, NOW);

        assertShortcutAtPosition("expecting app1 to beat all others since it's clicked last", "app", 0, mApp1);
    }

    /**
     * similar to {@link #testMoreClicksWins()} but clicks are reported with prefixes of the
     * original query.  we want to make sure a match on query 'a' updates the stats for the
     * entry it matched against, 'app'.
     */
    public void testPrefixMatchUpdatesSameEntry() {
        reportClick("app", mApp1, NOW);
        reportClick("app", mApp2, NOW);
        reportClick("app", mApp1, NOW);

        assertShortcuts("expected app1 to beat app2 since it has more hits", "app", mApp1, mApp2);
    }

    private static final long DAY_MILLIS = 86400000L; // just ask the google
    private static final long HOUR_MILLIS = 3600000L;

    public void testMoreRecentlyClickedWins() {
        reportClick("app", mApp1, NOW - DAY_MILLIS * 2);
        reportClick("app", mApp2, NOW);
        reportClick("app", mApp3, NOW - DAY_MILLIS * 4);

        assertShortcuts("expecting more recently clicked app to rank higher", "app", mApp2, mApp1, mApp3);
    }

    public void testMoreRecentlyClickedWinsSeconds() {
        reportClick("app", mApp1, NOW - 10000);
        reportClick("app", mApp2, NOW - 5000);
        reportClick("app", mApp3, NOW);

        assertShortcuts("expecting more recently clicked app to rank higher", "app", mApp3, mApp2, mApp1);
    }

    public void testRecencyOverridesClicks() {

        // 5 clicks, most recent half way through age limit
        long halfWindow = mConfig.getMaxStatAgeMillis() / 2;
        reportClick("app", mApp1, NOW - halfWindow);
        reportClick("app", mApp1, NOW - halfWindow);
        reportClick("app", mApp1, NOW - halfWindow);
        reportClick("app", mApp1, NOW - halfWindow);
        reportClick("app", mApp1, NOW - halfWindow);

        // 3 clicks, the most recent very recent
        reportClick("app", mApp2, NOW - HOUR_MILLIS);
        reportClick("app", mApp2, NOW - HOUR_MILLIS);
        reportClick("app", mApp2, NOW - HOUR_MILLIS);

        assertShortcuts("expecting 3 recent clicks to beat 5 clicks long ago", "app", mApp2, mApp1);
    }

    public void testEntryOlderThanAgeLimitFiltered() {
        reportClick("app", mApp1);

        long pastWindow = mConfig.getMaxStatAgeMillis() + 1000;
        reportClick("app", mApp2, NOW - pastWindow);

        assertShortcuts("expecting app2 not clicked on recently enough to be filtered", "app", mApp1);
    }

    public void testZeroQueryResults_MoreClicksWins() {
        reportClick("app", mApp1);
        reportClick("app", mApp1);
        reportClick("foo", mApp2);

        assertShortcuts("", mApp1, mApp2);

        reportClick("foo", mApp2);
        reportClick("foo", mApp2);

        assertShortcuts("", mApp2, mApp1);
    }

    public void testZeroQueryResults_DifferentQueryhitsCreditSameShortcut() {
        reportClick("app", mApp1);
        reportClick("foo", mApp2);
        reportClick("bar", mApp2);

        assertShortcuts("hits for 'foo' and 'bar' on app2 should have combined to rank it "
                + "ahead of app1, which only has one hit.", "", mApp2, mApp1);

        reportClick("z", mApp1);
        reportClick("2", mApp1);

        assertShortcuts("", mApp1, mApp2);
    }

    public void testZeroQueryResults_zeroQueryHitCounts() {
        reportClick("app", mApp1);
        reportClick("", mApp2);
        reportClick("", mApp2);

        assertShortcuts(
                "hits for '' on app2 should have combined to rank it " + "ahead of app1, which only has one hit.",
                "", mApp2, mApp1);

        reportClick("", mApp1);
        reportClick("", mApp1);

        assertShortcuts("zero query hits for app1 should have made it higher than app2.", "", mApp1, mApp2);

        assertShortcuts("query for 'a' should only match app1.", "a", mApp1);
    }

    public void testRefreshShortcut() {
        final SuggestionData app1 = new SuggestionData(APP_SOURCE).setFormat("format").setText1("app1")
                .setText2("cool app").setShortcutId("app1_id");

        reportClick("app", app1);

        final SuggestionData updated = new SuggestionData(APP_SOURCE).setFormat("format (updated)")
                .setText1("app1 (updated)").setText2("cool app").setShortcutId("app1_id");

        refreshShortcut(APP_SOURCE, "app1_id", updated);

        assertShortcuts("expected updated properties in match", "app", updated);
    }

    public void testRefreshShortcutChangedIntent() {

        final SuggestionData app1 = new SuggestionData(APP_SOURCE).setIntentData("data").setFormat("format")
                .setText1("app1").setText2("cool app").setShortcutId("app1_id");

        reportClick("app", app1);

        final SuggestionData updated = new SuggestionData(APP_SOURCE).setIntentData("data-updated")
                .setFormat("format (updated)").setText1("app1 (updated)").setText2("cool app")
                .setShortcutId("app1_id");

        refreshShortcut(APP_SOURCE, "app1_id", updated);

        assertShortcuts("expected updated properties in match", "app", updated);
    }

    public void testInvalidateShortcut() {
        final SuggestionData app1 = new SuggestionData(APP_SOURCE).setText1("app1").setText2("cool app")
                .setShortcutId("app1_id");

        reportClick("app", app1);

        invalidateShortcut(APP_SOURCE, "app1_id");

        assertNoShortcuts("should be no matches since shortcut is invalid.", "app");
    }

    public void testInvalidateShortcut_sameIdDifferentSources() {
        final String sameid = "same_id";
        final SuggestionData app = new SuggestionData(APP_SOURCE).setText1("app1").setText2("cool app")
                .setShortcutId(sameid);
        reportClick("app", app);
        assertShortcuts("app should be there", "", app);

        final SuggestionData contact = new SuggestionData(CONTACTS_SOURCE).setText1("joe blow")
                .setText2("a good pal").setShortcutId(sameid);
        reportClick("joe", contact);
        reportClick("joe", contact);
        assertShortcuts("app and contact should be there.", "", contact, app);

        refreshShortcut(APP_SOURCE, sameid, null);
        assertNoShortcuts("app should not be there.", "app");
        assertShortcuts("contact with same shortcut id should still be there.", "joe", contact);
        assertShortcuts("contact with same shortcut id should still be there.", "", contact);
    }

    public void testNeverMakeShortcut() {
        final SuggestionData contact = new SuggestionData(CONTACTS_SOURCE).setText1("unshortcuttable contact")
                .setText2("you didn't want to call them again anyway")
                .setShortcutId(SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT);
        reportClick("unshortcuttable", contact);
        assertNoShortcuts("never-shortcutted suggestion should not be there.", "unshortcuttable");
    }

    public void testCountResetAfterShortcutDeleted() {
        reportClick("app", mApp1);
        reportClick("app", mApp1);
        reportClick("app", mApp1);
        reportClick("app", mApp1);

        reportClick("app", mApp2);
        reportClick("app", mApp2);

        // app1 wins 4 - 2
        assertShortcuts("app", mApp1, mApp2);

        // reset to 1
        invalidateShortcut(APP_SOURCE, mApp1.getShortcutId());
        reportClick("app", mApp1);

        // app2 wins 2 - 1
        assertShortcuts("expecting app1's click count to reset after being invalidated.", "app", mApp2, mApp1);
    }

    public void testShortcutsAllowedCorpora() {
        reportClick("a", mApp1);
        reportClick("a", mContact1);

        assertShortcuts("only allowed shortcuts should be returned", "a", Arrays.asList(APP_CORPUS), mApp1);
    }

    //
    // SOURCE RANKING TESTS BELOW
    //

    public void testSourceRanking_moreClicksWins() {
        assertCorpusRanking("expected no ranking");

        int minClicks = mConfig.getMinClicksForSourceRanking();

        // click on an app
        for (int i = 0; i < minClicks + 1; i++) {
            reportClick("a", mApp1);
        }
        // fewer clicks on a contact
        for (int i = 0; i < minClicks; i++) {
            reportClick("a", mContact1);
        }

        assertCorpusRanking("expecting apps to rank ahead of contacts (more clicks)", APP_CORPUS, CONTACTS_CORPUS);

        // more clicks on a contact
        reportClick("a", mContact1);
        reportClick("a", mContact1);

        assertCorpusRanking("expecting contacts to rank ahead of apps (more clicks)", CONTACTS_CORPUS, APP_CORPUS);
    }

    public void testOldSourceStatsDontCount() {
        // apps were popular back in the day
        final long toOld = mConfig.getMaxStatAgeMillis() + 1;
        int minClicks = mConfig.getMinClicksForSourceRanking();
        for (int i = 0; i < minClicks; i++) {
            reportClick("app", mApp1, NOW - toOld);
        }

        // and contacts is 1/2
        for (int i = 0; i < minClicks; i++) {
            reportClick("bob", mContact1, NOW);
        }

        assertCorpusRanking("old clicks for apps shouldn't count.", CONTACTS_CORPUS);
    }

    public void testSourceRanking_filterSourcesWithInsufficientData() {
        int minClicks = mConfig.getMinClicksForSourceRanking();
        // not enough
        for (int i = 0; i < minClicks - 1; i++) {
            reportClick("app", mApp1);
        }
        // just enough
        for (int i = 0; i < minClicks; i++) {
            reportClick("bob", mContact1);
        }

        assertCorpusRanking("ordering should only include sources with at least " + minClicks + " clicks.",
                CONTACTS_CORPUS);
    }

    // App upgrade tests

    public void testAppUpgradeClearsShortcuts() {
        reportClick("a", mApp1);
        reportClick("add", mApp1);
        reportClick("a", mContact1);

        assertShortcuts("all shortcuts should be returned", "a", mAllowedCorpora, mApp1, mContact1);

        // Upgrade an existing corpus
        MockCorpus upgradedCorpus = new MockCorpus(APP_SOURCE_V2);
        mCorpora.addCorpus(upgradedCorpus);

        List<Corpus> newAllowedCorpora = new ArrayList<Corpus>(mCorpora.getAllCorpora());
        assertShortcuts("app shortcuts should be removed when the source was upgraded", "a", newAllowedCorpora,
                mContact1);
    }

    public void testAppUpgradePromotesLowerRanked() {

        ListSuggestionCursor expected = new ListSuggestionCursor("a");
        for (int i = 0; i < MAX_SHORTCUTS + 1; i++) {
            reportClick("app", mApp1, NOW);
        }
        expected.add(mApp1);

        // Enough contact clicks to make one more shortcut than getMaxShortcutsReturned()
        for (int i = 0; i < MAX_SHORTCUTS; i++) {
            SuggestionData contact = makeContact("andy" + i);
            int numClicks = MAX_SHORTCUTS - i; // use click count to get shortcuts in order
            for (int j = 0; j < numClicks; j++) {
                reportClick("and", contact, NOW);
            }
            expected.add(contact);
        }

        // Expect the app, and then all contacts
        assertShortcuts("app and all contacts should be returned", "a", mAllowedCorpora, expected);

        // Upgrade app corpus
        MockCorpus upgradedCorpus = new MockCorpus(APP_SOURCE_V2);
        mCorpora.addCorpus(upgradedCorpus);

        // Expect all contacts
        List<Corpus> newAllowedCorpora = new ArrayList<Corpus>(mCorpora.getAllCorpora());
        assertShortcuts(
                "app shortcuts should be removed when the source was upgraded "
                        + "and a contact should take its place",
                "a", newAllowedCorpora, SuggestionCursorUtil.slice(expected, 1));
    }

    public void testIrrelevantAppUpgrade() {
        reportClick("a", mApp1);
        reportClick("add", mApp1);
        reportClick("a", mContact1);

        assertShortcuts("all shortcuts should be returned", "a", mAllowedCorpora, mApp1, mContact1);

        // Fire a corpus set update that affect no shortcuts corpus
        MockCorpus newCorpus = new MockCorpus(new MockSource("newsource"));
        mCorpora.addCorpus(newCorpus);

        assertShortcuts("all shortcuts should be returned", "a", mAllowedCorpora, mApp1, mContact1);
    }

    public void testAllowWebSearchShortcuts() {
        reportClick("a", mApp1);
        reportClick("a", mApp1);
        reportClick("a", mWeb1);
        assertShortcuts("web shortcuts should be included", "a", mAllowedCorpora, true, mApp1, mWeb1);
        assertShortcuts("web shortcuts should not be included", "a", mAllowedCorpora, false, mApp1);
    }

    public void testExtraDataNull() {
        assertExtra("Null extra", "extra_null", null);
    }

    public void testExtraDataString() {
        assertExtra("String extra", "extra_string", "stringy-stringy-string");
    }

    public void testExtraDataInteger() {
        assertExtra("Integer extra", "extra_int", new Integer(42));
    }

    public void testExtraDataFloat() {
        assertExtra("Float extra", "extra_float", new Float(Math.PI));
    }

    public void testExtraDataStringWithDodgyChars() {
        assertExtra("String extra with newlines", "extra_string", "line\nline\nline\n");
        JSONArray a = new JSONArray();
        a.put(true);
        a.put(42);
        a.put("hello");
        a.put("hello \"again\"");
        assertExtra("String extra with JSON", "extra_string", a.toString());
        assertExtra("String extra with control chars", "extra_string", "\0\b\t\f\r");
    }

    // Utilities

    protected ListSuggestionCursor makeCursor(String query, SuggestionData... suggestions) {
        ListSuggestionCursor cursor = new ListSuggestionCursor(query);
        for (SuggestionData suggestion : suggestions) {
            cursor.add(suggestion);
        }
        return cursor;
    }

    protected void reportClick(String query, SuggestionData suggestion) {
        reportClick(new ListSuggestionCursor(query, suggestion), 0);
    }

    protected void reportClick(String query, SuggestionData suggestion, long now) {
        reportClickAtTime(new ListSuggestionCursor(query, suggestion), 0, now);
    }

    protected void reportClick(SuggestionCursor suggestions, int position) {
        reportClickAtTime(suggestions, position, NOW);
    }

    protected void reportClickAtTime(SuggestionCursor suggestions, int position, long now) {
        mRepo.reportClickAtTime(suggestions, position, now);
        mLogExecutor.runNext();
    }

    protected void removeFromHistory(SuggestionCursor suggestions, int position) {
        mRepo.removeFromHistory(suggestions, position);
        mLogExecutor.runNext();
    }

    protected void invalidateShortcut(Source source, String shortcutId) {
        refreshShortcut(source, shortcutId, null);
    }

    protected void refreshShortcut(Source source, String shortcutId, SuggestionData suggestion) {
        SuggestionCursor refreshed = suggestion == null ? null : new ListSuggestionCursor(null, suggestion);
        mRepo.refreshShortcut(source, shortcutId, refreshed);
        mLogExecutor.runNext();
    }

    protected void sourceImpressions(Source source, int clicks, int impressions) {
        if (clicks > impressions)
            throw new IllegalArgumentException("ya moran!");

        for (int i = 0; i < impressions; i++, clicks--) {
            sourceImpression(source, clicks > 0);
        }
    }

    /**
     * Simulate an impression, and optionally a click, on a source.
     *
     * @param source The name of the source.
     * @param click Whether to register a click in addition to the impression.
     */
    protected void sourceImpression(Source source, boolean click) {
        sourceImpression(source, click, NOW);
    }

    protected SuggestionData sourceSuggestion(Source source) {
        return new SuggestionData(source).setIntentAction("view").setIntentData("data/id")
                .setShortcutId("shortcutid");
    }

    /**
     * Simulate an impression, and optionally a click, on a source.
     *
     * @param source The name of the source.
     * @param click Whether to register a click in addition to the impression.
     */
    protected void sourceImpression(Source source, boolean click, long now) {
        SuggestionData suggestionClicked = !click ? null : sourceSuggestion(source);

        reportClick("a", suggestionClicked);
    }

    void assertNoShortcuts(String query) {
        assertNoShortcuts("", query);
    }

    void assertNoShortcuts(String message, String query) {
        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
        try {
            assertNull(message + ", got shortcuts", cursor);
        } finally {
            if (cursor != null)
                cursor.close();
        }
    }

    void assertShortcuts(String query, SuggestionData... expected) {
        assertShortcuts("", query, expected);
    }

    void assertShortcutAtPosition(String message, String query, int position, SuggestionData expected) {
        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
        try {
            SuggestionCursor expectedCursor = new ListSuggestionCursor(query, expected);
            SuggestionCursorUtil.assertSameSuggestion(message, position, expectedCursor, cursor);
        } finally {
            if (cursor != null)
                cursor.close();
        }
    }

    void assertShortcutCount(String message, String query, int expectedCount) {
        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
        try {
            assertEquals(message, expectedCount, cursor.getCount());
        } finally {
            if (cursor != null)
                cursor.close();
        }
    }

    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
            boolean allowWebSearchShortcuts, SuggestionCursor expected) {
        SuggestionCursor cursor = mRepo.getShortcutsForQuery(query, allowedCorpora, allowWebSearchShortcuts, NOW);
        try {
            SuggestionCursorUtil.assertSameSuggestions(message, expected, cursor);
        } finally {
            if (cursor != null)
                cursor.close();
        }
    }

    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
            SuggestionCursor expected) {
        assertShortcuts(message, query, allowedCorpora, true, expected);
    }

    SuggestionCursor getShortcuts(String query, Collection<Corpus> allowedCorpora) {
        return mRepo.getShortcutsForQuery(query, allowedCorpora, true, NOW);
    }

    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
            boolean allowWebSearchShortcuts, SuggestionData... expected) {
        assertShortcuts(message, query, allowedCorpora, allowWebSearchShortcuts,
                new ListSuggestionCursor(query, expected));
    }

    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
            SuggestionData... expected) {
        assertShortcuts(message, query, allowedCorpora, new ListSuggestionCursor(query, expected));
    }

    void assertShortcuts(String message, String query, SuggestionData... expected) {
        assertShortcuts(message, query, mAllowedCorpora, expected);
    }

    private void assertHasHistory(boolean expected) {
        ConsumerTrap<Boolean> trap = new ConsumerTrap<Boolean>();
        mRepo.hasHistory(trap);
        mLogExecutor.runNext();
        assertEquals("hasHistory() returned bad value", expected, (boolean) trap.getValue());
    }

    void assertCorpusRanking(String message, Corpus... expected) {
        String[] expectedNames = new String[expected.length];
        for (int i = 0; i < expected.length; i++) {
            expectedNames[i] = expected[i].getName();
        }
        Map<String, Integer> scores = getCorpusScores();
        List<String> observed = sortByValues(scores);
        // Highest scores should come first
        Collections.reverse(observed);
        Log.d(TAG, "scores=" + scores);
        MoreAsserts.assertContentsInOrder(message, observed, (Object[]) expectedNames);
    }

    private Map<String, Integer> getCorpusScores() {
        ConsumerTrap<Map<String, Integer>> trap = new ConsumerTrap<Map<String, Integer>>();
        mRepo.getCorpusScores(trap);
        mLogExecutor.runNext();
        return trap.getValue();
    }

    static <A extends Comparable<A>, B extends Comparable<B>> List<A> sortByValues(Map<A, B> map) {
        Comparator<Map.Entry<A, B>> comp = new Comparator<Map.Entry<A, B>>() {
            public int compare(Entry<A, B> object1, Entry<A, B> object2) {
                int diff = object1.getValue().compareTo(object2.getValue());
                if (diff != 0) {
                    return diff;
                } else {
                    return object1.getKey().compareTo(object2.getKey());
                }
            }
        };
        ArrayList<Map.Entry<A, B>> sorted = new ArrayList<Map.Entry<A, B>>(map.size());
        sorted.addAll(map.entrySet());
        Collections.sort(sorted, comp);
        ArrayList<A> out = new ArrayList<A>(sorted.size());
        for (Map.Entry<A, B> e : sorted) {
            out.add(e.getKey());
        }
        return out;
    }

    static void assertContentsInOrder(Iterable<?> actual, Object... expected) {
        MoreAsserts.assertContentsInOrder(null, actual, expected);
    }

    void assertExtra(String message, String extraColumn, Object extraValue) {
        SuggestionData s = sourceSuggestion(APP_SOURCE);
        s.setExtras(new MockSuggestionExtras().put(extraColumn, extraValue));
        reportClick("a", s);
        assertShortcutExtra(message, "a", extraColumn, extraValue);
    }

    void assertShortcutExtra(String message, String query, String extraColumn, Object extraValue) {
        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
        try {
            SuggestionCursorUtil.assertSuggestionExtras(message, cursor, extraColumn, extraValue);
        } finally {
            if (cursor != null)
                cursor.close();
        }
    }

}