1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.quicksearchbox;
17
18import com.android.quicksearchbox.util.MockExecutor;
19import com.android.quicksearchbox.util.Util;
20
21import org.json.JSONArray;
22
23import android.app.SearchManager;
24import android.content.Intent;
25import android.test.AndroidTestCase;
26import android.test.MoreAsserts;
27import android.test.suitebuilder.annotation.MediumTest;
28import android.util.Log;
29
30import java.util.ArrayList;
31import java.util.Arrays;
32import java.util.Collection;
33import java.util.Collections;
34import java.util.Comparator;
35import java.util.List;
36import java.util.Map;
37import java.util.Map.Entry;
38
39/**
40 * Abstract base class for tests of  {@link ShortcutRepository}
41 * implementations.  Most importantly, verifies the
42 * stuff we are doing with sqlite works how we expect it to.
43 *
44 * Attempts to test logic independent of the (sql) details of the implementation, so these should
45 * be useful even in the face of a schema change.
46 */
47@MediumTest
48public class ShortcutRepositoryTest extends AndroidTestCase {
49
50    private static final String TAG = "ShortcutRepositoryTest";
51
52    static final long NOW = 1239841162000L; // millis since epoch. some time in 2009
53
54    static final Source APP_SOURCE = new MockSource("com.example.app/.App");
55
56    static final Source APP_SOURCE_V2 = new MockSource("com.example.app/.App", 2);
57
58    static final Source CONTACTS_SOURCE = new MockSource("com.android.contacts/.Contacts");
59
60    static final Source BOOKMARKS_SOURCE = new MockSource("com.android.browser/.Bookmarks");
61
62    static final Source HISTORY_SOURCE = new MockSource("com.android.browser/.History");
63
64    static final Source MUSIC_SOURCE = new MockSource("com.android.music/.Music");
65
66    static final Source MARKET_SOURCE = new MockSource("com.android.vending/.Market");
67
68    static final Corpus APP_CORPUS = new MockCorpus(APP_SOURCE);
69
70    static final Corpus CONTACTS_CORPUS = new MockCorpus(CONTACTS_SOURCE);
71
72    static final Corpus WEB_CORPUS = new MockCorpus(MockSource.WEB_SOURCE);
73
74    static final int MAX_SHORTCUTS = 8;
75
76    protected Config mConfig;
77    protected MockCorpora mCorpora;
78    protected MockExecutor mLogExecutor;
79    protected ShortcutRefresher mRefresher;
80
81    protected List<Corpus> mAllowedCorpora;
82
83    protected ShortcutRepositoryImplLog mRepo;
84
85    protected ListSuggestionCursor mAppSuggestions;
86    protected ListSuggestionCursor mContactSuggestions;
87
88    protected SuggestionData mApp1;
89    protected SuggestionData mApp2;
90    protected SuggestionData mApp3;
91
92    protected SuggestionData mContact1;
93    protected SuggestionData mContact2;
94
95    protected SuggestionData mWeb1;
96
97    protected ShortcutRepositoryImplLog createShortcutRepository() {
98        return new ShortcutRepositoryImplLog(getContext(), mConfig, mCorpora,
99                mRefresher, new MockHandler(), mLogExecutor,
100                "test-shortcuts-log.db");
101    }
102
103    @Override
104    protected void setUp() throws Exception {
105        super.setUp();
106
107        mConfig = new Config(getContext());
108        mCorpora = new MockCorpora();
109        mCorpora.addCorpus(APP_CORPUS);
110        mCorpora.addCorpus(CONTACTS_CORPUS);
111        mCorpora.addCorpus(WEB_CORPUS);
112        mRefresher = new MockShortcutRefresher();
113        mLogExecutor = new MockExecutor();
114        mRepo = createShortcutRepository();
115
116        mAllowedCorpora = new ArrayList<Corpus>(mCorpora.getAllCorpora());
117
118        mApp1 = makeApp("app1");
119        mApp2 = makeApp("app2");
120        mApp3 = makeApp("app3");
121        mAppSuggestions = new ListSuggestionCursor("foo", mApp1, mApp2, mApp3);
122
123        mContact1 = new SuggestionData(CONTACTS_SOURCE)
124                .setText1("Joe Blow")
125                .setIntentAction("view")
126                .setIntentData("contacts/joeblow")
127                .setShortcutId("j-blow");
128        mContact2 = new SuggestionData(CONTACTS_SOURCE)
129                .setText1("Mike Johnston")
130                .setIntentAction("view")
131                .setIntentData("contacts/mikeJ")
132                .setShortcutId("mo-jo");
133
134        mWeb1 = new SuggestionData(MockSource.WEB_SOURCE)
135                .setText1("foo")
136                .setIntentAction(Intent.ACTION_WEB_SEARCH)
137                .setSuggestionQuery("foo");
138
139        mContactSuggestions = new ListSuggestionCursor("foo", mContact1, mContact2);
140    }
141
142    private SuggestionData makeApp(String name) {
143        return new SuggestionData(APP_SOURCE)
144                .setText1(name)
145                .setIntentAction("view")
146                .setIntentData("apps/" + name)
147                .setShortcutId("shorcut_" + name);
148    }
149
150    private SuggestionData makeContact(String name) {
151        return new SuggestionData(CONTACTS_SOURCE)
152                .setText1(name)
153                .setIntentAction("view")
154                .setIntentData("contacts/" + name)
155                .setShortcutId("shorcut_" + name);
156    }
157
158    @Override
159    protected void tearDown() throws Exception {
160        super.tearDown();
161        mRepo.deleteRepository();
162    }
163
164    public void testHasHistory() {
165        assertHasHistory(false);
166        reportClickAtTime(mAppSuggestions, 0, NOW);
167        assertHasHistory(true);
168        mRepo.clearHistory();
169        mLogExecutor.runNext();
170        assertHasHistory(false);
171    }
172
173    public void testRemoveFromHistory() {
174        SuggestionData john = new SuggestionData(CONTACTS_SOURCE)
175                .setText1("john doe")
176                .setIntentAction("view")
177                .setIntentData("john_doe");
178        SuggestionData jane = new SuggestionData(CONTACTS_SOURCE)
179                .setText1("jane doe")
180                .setIntentAction("view")
181                .setIntentData("jane_doe");
182        reportClick("j", john);
183        reportClick("j", john);
184        reportClick("j", jane);
185        assertShortcuts("j", john, jane);
186        removeFromHistory(new ListSuggestionCursor("j", jane, john), 1);
187        assertShortcuts("j", jane);
188    }
189
190    public void testRemoveFromHistoryNonExisting() {
191        SuggestionData john = new SuggestionData(CONTACTS_SOURCE)
192                .setText1("john doe")
193                .setIntentAction("view")
194                .setIntentData("john_doe");
195        SuggestionData jane = new SuggestionData(CONTACTS_SOURCE)
196                .setText1("jane doe")
197                .setIntentAction("view")
198                .setIntentData("jane_doe");
199        reportClick("j", john);
200        assertShortcuts("j", john);
201        removeFromHistory(new ListSuggestionCursor("j", jane), 0);
202        assertShortcuts("j", john);
203    }
204
205    public void testNoMatch() {
206        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE)
207                .setText1("bob smith")
208                .setIntentAction("action")
209                .setIntentData("data");
210
211        reportClick("bob smith", clicked);
212        assertNoShortcuts("joe");
213    }
214
215    public void testFullPackingUnpacking() {
216        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE)
217                .setFormat("<i>%s</i>")
218                .setText1("title")
219                .setText2("description")
220                .setText2Url("description_url")
221                .setIcon1("android.resource://system/drawable/foo")
222                .setIcon2("content://test/bar")
223                .setIntentAction("action")
224                .setIntentData("data")
225                .setSuggestionQuery("query")
226                .setIntentExtraData("extradata")
227                .setShortcutId("idofshortcut")
228                .setSuggestionLogType("logtype");
229        reportClick("q", clicked);
230
231        assertShortcuts("q", clicked);
232        assertShortcuts("", clicked);
233    }
234
235    public void testSpinnerWhileRefreshing() {
236        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE)
237                .setText1("title")
238                .setText2("description")
239                .setIcon2("icon2")
240                .setSuggestionQuery("query")
241                .setIntentExtraData("extradata")
242                .setShortcutId("idofshortcut")
243                .setSpinnerWhileRefreshing(true);
244
245        reportClick("q", clicked);
246
247        String spinnerUri = Util.getResourceUri(mContext, R.drawable.search_spinner).toString();
248        SuggestionData expected = new SuggestionData(CONTACTS_SOURCE)
249                .setText1("title")
250                .setText2("description")
251                .setIcon2(spinnerUri)
252                .setSuggestionQuery("query")
253                .setIntentExtraData("extradata")
254                .setShortcutId("idofshortcut")
255                .setSpinnerWhileRefreshing(true);
256
257        assertShortcuts("q", expected);
258    }
259
260    public void testPrefixesMatch() {
261        assertNoShortcuts("bob");
262
263        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE)
264                .setText1("bob smith the third")
265                .setIntentAction("action")
266                .setIntentData("intentdata");
267
268        reportClick("bob smith", clicked);
269
270        assertShortcuts("bob smith", clicked);
271        assertShortcuts("bob s", clicked);
272        assertShortcuts("b", clicked);
273    }
274
275    public void testMatchesOneAndNotOthers() {
276        SuggestionData bob = new SuggestionData(CONTACTS_SOURCE)
277                .setText1("bob smith the third")
278                .setIntentAction("action")
279                .setIntentData("intentdata/bob");
280
281        reportClick("bob", bob);
282
283        SuggestionData george = new SuggestionData(CONTACTS_SOURCE)
284                .setText1("george jones")
285                .setIntentAction("action")
286                .setIntentData("intentdata/george");
287        reportClick("geor", george);
288
289        assertShortcuts("b for bob", "b", bob);
290        assertShortcuts("g for george", "g", george);
291    }
292
293    public void testDifferentPrefixesMatchSameEntity() {
294        SuggestionData clicked = new SuggestionData(CONTACTS_SOURCE)
295                .setText1("bob smith the third")
296                .setIntentAction("action")
297                .setIntentData("intentdata");
298
299        reportClick("bob", clicked);
300        reportClick("smith", clicked);
301        assertShortcuts("b", clicked);
302        assertShortcuts("s", clicked);
303    }
304
305    public void testMoreClicksWins() {
306        reportClick("app", mApp1);
307        reportClick("app", mApp2);
308        reportClick("app", mApp1);
309
310        assertShortcuts("expected app1 to beat app2 since it has more hits",
311                "app", mApp1, mApp2);
312
313        reportClick("app", mApp2);
314        reportClick("app", mApp2);
315
316        assertShortcuts("query 'app': expecting app2 to beat app1 since it has more hits",
317                "app", mApp2, mApp1);
318        assertShortcuts("query 'a': expecting app2 to beat app1 since it has more hits",
319                "a", mApp2, mApp1);
320    }
321
322    public void testMostRecentClickWins() {
323        // App 1 has 3 clicks
324        reportClick("app", mApp1, NOW - 5);
325        reportClick("app", mApp1, NOW - 5);
326        reportClick("app", mApp1, NOW - 5);
327        // App 2 has 2 clicks
328        reportClick("app", mApp2, NOW - 2);
329        reportClick("app", mApp2, NOW - 2);
330        // App 3 only has 1, but it's most recent
331        reportClick("app", mApp3, NOW - 1);
332
333        assertShortcuts("expected app3 to beat app1 and app2 because it's clicked last",
334                "app", mApp3, mApp1, mApp2);
335
336        reportClick("app", mApp2, NOW);
337
338        assertShortcuts("query 'app': expecting app2 to beat app1 since it's clicked last",
339                "app", mApp2, mApp1, mApp3);
340        assertShortcuts("query 'a': expecting app2 to beat app1 since it's clicked last",
341                "a", mApp2, mApp1, mApp3);
342        assertShortcuts("query '': expecting app2 to beat app1 since it's clicked last",
343                "", mApp2, mApp1, mApp3);
344    }
345
346    public void testMostRecentClickWinsOnEmptyQuery() {
347        reportClick("app", mApp1, NOW - 3);
348        reportClick("app", mApp1, NOW - 2);
349        reportClick("app", mApp2, NOW - 1);
350
351        assertShortcuts("expected app2 to beat app1 since it's clicked last", "",
352                mApp2, mApp1);
353    }
354
355    public void testMostRecentClickWinsEvenWithMoreThanLimitShortcuts() {
356        for (int i = 0; i < MAX_SHORTCUTS; i++) {
357            SuggestionData app = makeApp("TestApp" + i);
358            // Each of these shortcuts has two clicks
359            reportClick("app", app, NOW - 2);
360            reportClick("app", app, NOW - 1);
361        }
362
363        // mApp1 has only one click, but is more recent
364        reportClick("app", mApp1, NOW);
365
366        assertShortcutAtPosition(
367            "expecting app1 to beat all others since it's clicked last",
368            "app", 0, mApp1);
369    }
370
371    /**
372     * similar to {@link #testMoreClicksWins()} but clicks are reported with prefixes of the
373     * original query.  we want to make sure a match on query 'a' updates the stats for the
374     * entry it matched against, 'app'.
375     */
376    public void testPrefixMatchUpdatesSameEntry() {
377        reportClick("app", mApp1, NOW);
378        reportClick("app", mApp2, NOW);
379        reportClick("app", mApp1, NOW);
380
381        assertShortcuts("expected app1 to beat app2 since it has more hits",
382                "app", mApp1, mApp2);
383    }
384
385    private static final long DAY_MILLIS = 86400000L; // just ask the google
386    private static final long HOUR_MILLIS = 3600000L;
387
388    public void testMoreRecentlyClickedWins() {
389        reportClick("app", mApp1, NOW - DAY_MILLIS*2);
390        reportClick("app", mApp2, NOW);
391        reportClick("app", mApp3, NOW - DAY_MILLIS*4);
392
393        assertShortcuts("expecting more recently clicked app to rank higher",
394                "app", mApp2, mApp1, mApp3);
395    }
396
397    public void testMoreRecentlyClickedWinsSeconds() {
398        reportClick("app", mApp1, NOW - 10000);
399        reportClick("app", mApp2, NOW - 5000);
400        reportClick("app", mApp3, NOW);
401
402        assertShortcuts("expecting more recently clicked app to rank higher",
403                "app", mApp3, mApp2, mApp1);
404    }
405
406    public void testRecencyOverridesClicks() {
407
408        // 5 clicks, most recent half way through age limit
409        long halfWindow = mConfig.getMaxStatAgeMillis() / 2;
410        reportClick("app", mApp1, NOW - halfWindow);
411        reportClick("app", mApp1, NOW - halfWindow);
412        reportClick("app", mApp1, NOW - halfWindow);
413        reportClick("app", mApp1, NOW - halfWindow);
414        reportClick("app", mApp1, NOW - halfWindow);
415
416        // 3 clicks, the most recent very recent
417        reportClick("app", mApp2, NOW - HOUR_MILLIS);
418        reportClick("app", mApp2, NOW - HOUR_MILLIS);
419        reportClick("app", mApp2, NOW - HOUR_MILLIS);
420
421        assertShortcuts("expecting 3 recent clicks to beat 5 clicks long ago",
422                "app", mApp2, mApp1);
423    }
424
425    public void testEntryOlderThanAgeLimitFiltered() {
426        reportClick("app", mApp1);
427
428        long pastWindow = mConfig.getMaxStatAgeMillis() + 1000;
429        reportClick("app", mApp2, NOW - pastWindow);
430
431        assertShortcuts("expecting app2 not clicked on recently enough to be filtered",
432                "app", mApp1);
433    }
434
435    public void testZeroQueryResults_MoreClicksWins() {
436        reportClick("app", mApp1);
437        reportClick("app", mApp1);
438        reportClick("foo", mApp2);
439
440        assertShortcuts("", mApp1, mApp2);
441
442        reportClick("foo", mApp2);
443        reportClick("foo", mApp2);
444
445        assertShortcuts("", mApp2, mApp1);
446    }
447
448    public void testZeroQueryResults_DifferentQueryhitsCreditSameShortcut() {
449        reportClick("app", mApp1);
450        reportClick("foo", mApp2);
451        reportClick("bar", mApp2);
452
453        assertShortcuts("hits for 'foo' and 'bar' on app2 should have combined to rank it " +
454                "ahead of app1, which only has one hit.",
455                "", mApp2, mApp1);
456
457        reportClick("z", mApp1);
458        reportClick("2", mApp1);
459
460        assertShortcuts("", mApp1, mApp2);
461    }
462
463    public void testZeroQueryResults_zeroQueryHitCounts() {
464        reportClick("app", mApp1);
465        reportClick("", mApp2);
466        reportClick("", mApp2);
467
468        assertShortcuts("hits for '' on app2 should have combined to rank it " +
469                "ahead of app1, which only has one hit.",
470                "", mApp2, mApp1);
471
472        reportClick("", mApp1);
473        reportClick("", mApp1);
474
475        assertShortcuts("zero query hits for app1 should have made it higher than app2.",
476                "", mApp1, mApp2);
477
478        assertShortcuts("query for 'a' should only match app1.",
479                "a", mApp1);
480    }
481
482    public void testRefreshShortcut() {
483        final SuggestionData app1 = new SuggestionData(APP_SOURCE)
484                .setFormat("format")
485                .setText1("app1")
486                .setText2("cool app")
487                .setShortcutId("app1_id");
488
489        reportClick("app", app1);
490
491        final SuggestionData updated = new SuggestionData(APP_SOURCE)
492                .setFormat("format (updated)")
493                .setText1("app1 (updated)")
494                .setText2("cool app")
495                .setShortcutId("app1_id");
496
497        refreshShortcut(APP_SOURCE, "app1_id", updated);
498
499        assertShortcuts("expected updated properties in match",
500                "app", updated);
501    }
502
503    public void testRefreshShortcutChangedIntent() {
504
505        final SuggestionData app1 = new SuggestionData(APP_SOURCE)
506                .setIntentData("data")
507                .setFormat("format")
508                .setText1("app1")
509                .setText2("cool app")
510                .setShortcutId("app1_id");
511
512        reportClick("app", app1);
513
514        final SuggestionData updated = new SuggestionData(APP_SOURCE)
515                .setIntentData("data-updated")
516                .setFormat("format (updated)")
517                .setText1("app1 (updated)")
518                .setText2("cool app")
519                .setShortcutId("app1_id");
520
521        refreshShortcut(APP_SOURCE, "app1_id", updated);
522
523        assertShortcuts("expected updated properties in match",
524                "app", updated);
525    }
526
527    public void testInvalidateShortcut() {
528        final SuggestionData app1 = new SuggestionData(APP_SOURCE)
529                .setText1("app1")
530                .setText2("cool app")
531                .setShortcutId("app1_id");
532
533        reportClick("app", app1);
534
535        invalidateShortcut(APP_SOURCE, "app1_id");
536
537        assertNoShortcuts("should be no matches since shortcut is invalid.", "app");
538    }
539
540    public void testInvalidateShortcut_sameIdDifferentSources() {
541        final String sameid = "same_id";
542        final SuggestionData app = new SuggestionData(APP_SOURCE)
543                .setText1("app1")
544                .setText2("cool app")
545                .setShortcutId(sameid);
546        reportClick("app", app);
547        assertShortcuts("app should be there", "", app);
548
549        final SuggestionData contact = new SuggestionData(CONTACTS_SOURCE)
550                .setText1("joe blow")
551                .setText2("a good pal")
552                .setShortcutId(sameid);
553        reportClick("joe", contact);
554        reportClick("joe", contact);
555        assertShortcuts("app and contact should be there.", "", contact, app);
556
557        refreshShortcut(APP_SOURCE, sameid, null);
558        assertNoShortcuts("app should not be there.", "app");
559        assertShortcuts("contact with same shortcut id should still be there.",
560                "joe", contact);
561        assertShortcuts("contact with same shortcut id should still be there.",
562                "", contact);
563    }
564
565    public void testNeverMakeShortcut() {
566        final SuggestionData contact = new SuggestionData(CONTACTS_SOURCE)
567                .setText1("unshortcuttable contact")
568                .setText2("you didn't want to call them again anyway")
569                .setShortcutId(SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT);
570        reportClick("unshortcuttable", contact);
571        assertNoShortcuts("never-shortcutted suggestion should not be there.", "unshortcuttable");
572    }
573
574    public void testCountResetAfterShortcutDeleted() {
575        reportClick("app", mApp1);
576        reportClick("app", mApp1);
577        reportClick("app", mApp1);
578        reportClick("app", mApp1);
579
580        reportClick("app", mApp2);
581        reportClick("app", mApp2);
582
583        // app1 wins 4 - 2
584        assertShortcuts("app", mApp1, mApp2);
585
586        // reset to 1
587        invalidateShortcut(APP_SOURCE, mApp1.getShortcutId());
588        reportClick("app", mApp1);
589
590        // app2 wins 2 - 1
591        assertShortcuts("expecting app1's click count to reset after being invalidated.",
592                "app", mApp2, mApp1);
593    }
594
595    public void testShortcutsAllowedCorpora() {
596        reportClick("a", mApp1);
597        reportClick("a", mContact1);
598
599        assertShortcuts("only allowed shortcuts should be returned",
600                "a", Arrays.asList(APP_CORPUS), mApp1);
601    }
602
603    //
604    // SOURCE RANKING TESTS BELOW
605    //
606
607    public void testSourceRanking_moreClicksWins() {
608        assertCorpusRanking("expected no ranking");
609
610        int minClicks = mConfig.getMinClicksForSourceRanking();
611
612        // click on an app
613        for (int i = 0; i < minClicks + 1; i++) {
614            reportClick("a", mApp1);
615        }
616        // fewer clicks on a contact
617        for (int i = 0; i < minClicks; i++) {
618            reportClick("a", mContact1);
619        }
620
621        assertCorpusRanking("expecting apps to rank ahead of contacts (more clicks)",
622                APP_CORPUS, CONTACTS_CORPUS);
623
624        // more clicks on a contact
625        reportClick("a", mContact1);
626        reportClick("a", mContact1);
627
628        assertCorpusRanking("expecting contacts to rank ahead of apps (more clicks)",
629                CONTACTS_CORPUS, APP_CORPUS);
630    }
631
632    public void testOldSourceStatsDontCount() {
633        // apps were popular back in the day
634        final long toOld = mConfig.getMaxStatAgeMillis() + 1;
635        int minClicks = mConfig.getMinClicksForSourceRanking();
636        for (int i = 0; i < minClicks; i++) {
637            reportClick("app", mApp1, NOW - toOld);
638        }
639
640        // and contacts is 1/2
641        for (int i = 0; i < minClicks; i++) {
642            reportClick("bob", mContact1, NOW);
643        }
644
645        assertCorpusRanking("old clicks for apps shouldn't count.",
646                CONTACTS_CORPUS);
647    }
648
649
650    public void testSourceRanking_filterSourcesWithInsufficientData() {
651        int minClicks = mConfig.getMinClicksForSourceRanking();
652        // not enough
653        for (int i = 0; i < minClicks - 1; i++) {
654            reportClick("app", mApp1);
655        }
656        // just enough
657        for (int i = 0; i < minClicks; i++) {
658            reportClick("bob", mContact1);
659        }
660
661        assertCorpusRanking(
662                "ordering should only include sources with at least " + minClicks + " clicks.",
663                CONTACTS_CORPUS);
664    }
665
666    // App upgrade tests
667
668    public void testAppUpgradeClearsShortcuts() {
669        reportClick("a", mApp1);
670        reportClick("add", mApp1);
671        reportClick("a", mContact1);
672
673        assertShortcuts("all shortcuts should be returned",
674                "a", mAllowedCorpora, mApp1, mContact1);
675
676        // Upgrade an existing corpus
677        MockCorpus upgradedCorpus = new MockCorpus(APP_SOURCE_V2);
678        mCorpora.addCorpus(upgradedCorpus);
679
680        List<Corpus> newAllowedCorpora = new ArrayList<Corpus>(mCorpora.getAllCorpora());
681        assertShortcuts("app shortcuts should be removed when the source was upgraded",
682                "a", newAllowedCorpora, mContact1);
683    }
684
685    public void testAppUpgradePromotesLowerRanked() {
686
687        ListSuggestionCursor expected = new ListSuggestionCursor("a");
688        for (int i = 0; i < MAX_SHORTCUTS + 1; i++) {
689            reportClick("app", mApp1, NOW);
690        }
691        expected.add(mApp1);
692
693        // Enough contact clicks to make one more shortcut than getMaxShortcutsReturned()
694        for (int i = 0; i < MAX_SHORTCUTS; i++) {
695            SuggestionData contact = makeContact("andy" + i);
696            int numClicks = MAX_SHORTCUTS - i;  // use click count to get shortcuts in order
697            for (int j = 0; j < numClicks; j++) {
698                reportClick("and", contact, NOW);
699            }
700            expected.add(contact);
701        }
702
703        // Expect the app, and then all contacts
704        assertShortcuts("app and all contacts should be returned",
705                "a", mAllowedCorpora, expected);
706
707        // Upgrade app corpus
708        MockCorpus upgradedCorpus = new MockCorpus(APP_SOURCE_V2);
709        mCorpora.addCorpus(upgradedCorpus);
710
711        // Expect all contacts
712        List<Corpus> newAllowedCorpora = new ArrayList<Corpus>(mCorpora.getAllCorpora());
713        assertShortcuts("app shortcuts should be removed when the source was upgraded "
714                + "and a contact should take its place",
715                "a", newAllowedCorpora, SuggestionCursorUtil.slice(expected, 1));
716    }
717
718    public void testIrrelevantAppUpgrade() {
719        reportClick("a", mApp1);
720        reportClick("add", mApp1);
721        reportClick("a", mContact1);
722
723        assertShortcuts("all shortcuts should be returned",
724                "a", mAllowedCorpora, mApp1, mContact1);
725
726        // Fire a corpus set update that affect no shortcuts corpus
727        MockCorpus newCorpus = new MockCorpus(new MockSource("newsource"));
728        mCorpora.addCorpus(newCorpus);
729
730        assertShortcuts("all shortcuts should be returned",
731                "a", mAllowedCorpora, mApp1, mContact1);
732    }
733
734    public void testAllowWebSearchShortcuts() {
735        reportClick("a", mApp1);
736        reportClick("a", mApp1);
737        reportClick("a", mWeb1);
738        assertShortcuts("web shortcuts should be included", "a",
739                mAllowedCorpora, true, mApp1, mWeb1);
740        assertShortcuts("web shortcuts should not be included", "a",
741                mAllowedCorpora, false, mApp1);
742    }
743
744    public void testExtraDataNull() {
745        assertExtra("Null extra", "extra_null", null);
746    }
747
748    public void testExtraDataString() {
749        assertExtra("String extra", "extra_string", "stringy-stringy-string");
750    }
751
752    public void testExtraDataInteger() {
753        assertExtra("Integer extra", "extra_int", new Integer(42));
754    }
755
756    public void testExtraDataFloat() {
757        assertExtra("Float extra", "extra_float", new Float(Math.PI));
758    }
759
760    public void testExtraDataStringWithDodgyChars() {
761        assertExtra("String extra with newlines", "extra_string", "line\nline\nline\n");
762        JSONArray a = new JSONArray();
763        a.put(true);
764        a.put(42);
765        a.put("hello");
766        a.put("hello \"again\"");
767        assertExtra("String extra with JSON", "extra_string", a.toString());
768        assertExtra("String extra with control chars", "extra_string", "\0\b\t\f\r");
769    }
770
771    // Utilities
772
773    protected ListSuggestionCursor makeCursor(String query, SuggestionData... suggestions) {
774        ListSuggestionCursor cursor = new ListSuggestionCursor(query);
775        for (SuggestionData suggestion : suggestions) {
776            cursor.add(suggestion);
777        }
778        return cursor;
779    }
780
781    protected void reportClick(String query, SuggestionData suggestion) {
782        reportClick(new ListSuggestionCursor(query, suggestion), 0);
783    }
784
785    protected void reportClick(String query, SuggestionData suggestion, long now) {
786        reportClickAtTime(new ListSuggestionCursor(query, suggestion), 0, now);
787    }
788
789    protected void reportClick(SuggestionCursor suggestions, int position) {
790        reportClickAtTime(suggestions, position, NOW);
791    }
792
793    protected void reportClickAtTime(SuggestionCursor suggestions, int position, long now) {
794        mRepo.reportClickAtTime(suggestions, position, now);
795        mLogExecutor.runNext();
796    }
797
798    protected void removeFromHistory(SuggestionCursor suggestions, int position) {
799        mRepo.removeFromHistory(suggestions, position);
800        mLogExecutor.runNext();
801    }
802
803    protected void invalidateShortcut(Source source, String shortcutId) {
804        refreshShortcut(source, shortcutId, null);
805    }
806
807    protected void refreshShortcut(Source source, String shortcutId, SuggestionData suggestion) {
808        SuggestionCursor refreshed =
809                suggestion == null ? null : new ListSuggestionCursor(null, suggestion);
810        mRepo.refreshShortcut(source, shortcutId, refreshed);
811        mLogExecutor.runNext();
812    }
813
814    protected void sourceImpressions(Source source, int clicks, int impressions) {
815        if (clicks > impressions) throw new IllegalArgumentException("ya moran!");
816
817        for (int i = 0; i < impressions; i++, clicks--) {
818            sourceImpression(source, clicks > 0);
819        }
820    }
821
822    /**
823     * Simulate an impression, and optionally a click, on a source.
824     *
825     * @param source The name of the source.
826     * @param click Whether to register a click in addition to the impression.
827     */
828    protected void sourceImpression(Source source, boolean click) {
829        sourceImpression(source, click, NOW);
830    }
831
832    protected SuggestionData sourceSuggestion(Source source) {
833        return new SuggestionData(source)
834            .setIntentAction("view")
835            .setIntentData("data/id")
836            .setShortcutId("shortcutid");
837    }
838
839    /**
840     * Simulate an impression, and optionally a click, on a source.
841     *
842     * @param source The name of the source.
843     * @param click Whether to register a click in addition to the impression.
844     */
845    protected void sourceImpression(Source source, boolean click, long now) {
846        SuggestionData suggestionClicked = !click ?
847                null : sourceSuggestion(source);
848
849        reportClick("a", suggestionClicked);
850    }
851
852    void assertNoShortcuts(String query) {
853        assertNoShortcuts("", query);
854    }
855
856    void assertNoShortcuts(String message, String query) {
857        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
858        try {
859            assertNull(message + ", got shortcuts", cursor);
860        } finally {
861            if (cursor != null) cursor.close();
862        }
863    }
864
865    void assertShortcuts(String query, SuggestionData... expected) {
866        assertShortcuts("", query, expected);
867    }
868
869    void assertShortcutAtPosition(String message, String query,
870            int position, SuggestionData expected) {
871        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
872        try {
873            SuggestionCursor expectedCursor = new ListSuggestionCursor(query, expected);
874            SuggestionCursorUtil.assertSameSuggestion(message, position, expectedCursor, cursor);
875        } finally {
876            if (cursor != null) cursor.close();
877        }
878    }
879
880    void assertShortcutCount(String message, String query, int expectedCount) {
881        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
882        try {
883            assertEquals(message, expectedCount, cursor.getCount());
884        } finally {
885            if (cursor != null) cursor.close();
886        }
887    }
888
889    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
890            boolean allowWebSearchShortcuts, SuggestionCursor expected) {
891        SuggestionCursor cursor = mRepo.getShortcutsForQuery(query, allowedCorpora, allowWebSearchShortcuts, NOW);
892        try {
893            SuggestionCursorUtil.assertSameSuggestions(message, expected, cursor);
894        } finally {
895            if (cursor != null) cursor.close();
896        }
897    }
898
899    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
900            SuggestionCursor expected) {
901        assertShortcuts(message, query, allowedCorpora, true, expected);
902    }
903
904    SuggestionCursor getShortcuts(String query, Collection<Corpus> allowedCorpora) {
905        return mRepo.getShortcutsForQuery(query, allowedCorpora, true, NOW);
906    }
907
908    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
909            boolean allowWebSearchShortcuts, SuggestionData... expected) {
910        assertShortcuts(message, query, allowedCorpora, allowWebSearchShortcuts,
911                new ListSuggestionCursor(query, expected));
912    }
913
914    void assertShortcuts(String message, String query, Collection<Corpus> allowedCorpora,
915            SuggestionData... expected) {
916        assertShortcuts(message, query, allowedCorpora, new ListSuggestionCursor(query, expected));
917    }
918
919    void assertShortcuts(String message, String query, SuggestionData... expected) {
920        assertShortcuts(message, query, mAllowedCorpora, expected);
921    }
922
923    private void assertHasHistory(boolean expected) {
924        ConsumerTrap<Boolean> trap = new ConsumerTrap<Boolean>();
925        mRepo.hasHistory(trap);
926        mLogExecutor.runNext();
927        assertEquals("hasHistory() returned bad value", expected, (boolean) trap.getValue());
928    }
929
930    void assertCorpusRanking(String message, Corpus... expected) {
931        String[] expectedNames = new String[expected.length];
932        for (int i = 0; i < expected.length; i++) {
933            expectedNames[i] = expected[i].getName();
934        }
935        Map<String,Integer> scores = getCorpusScores();
936        List<String> observed = sortByValues(scores);
937        // Highest scores should come first
938        Collections.reverse(observed);
939        Log.d(TAG, "scores=" + scores);
940        MoreAsserts.assertContentsInOrder(message, observed, (Object[]) expectedNames);
941    }
942
943    private Map<String,Integer> getCorpusScores() {
944        ConsumerTrap<Map<String,Integer>> trap = new ConsumerTrap<Map<String,Integer>>();
945        mRepo.getCorpusScores(trap);
946        mLogExecutor.runNext();
947        return trap.getValue();
948    }
949
950    static <A extends Comparable<A>, B extends Comparable<B>> List<A> sortByValues(Map<A,B> map) {
951        Comparator<Map.Entry<A,B>> comp = new Comparator<Map.Entry<A,B>>() {
952            public int compare(Entry<A, B> object1, Entry<A, B> object2) {
953                int diff = object1.getValue().compareTo(object2.getValue());
954                if (diff != 0) {
955                    return diff;
956                } else {
957                    return object1.getKey().compareTo(object2.getKey());
958                }
959            }
960        };
961        ArrayList<Map.Entry<A,B>> sorted = new ArrayList<Map.Entry<A,B>>(map.size());
962        sorted.addAll(map.entrySet());
963        Collections.sort(sorted, comp);
964        ArrayList<A> out = new ArrayList<A>(sorted.size());
965        for (Map.Entry<A,B> e : sorted) {
966            out.add(e.getKey());
967        }
968        return out;
969    }
970
971    static void assertContentsInOrder(Iterable<?> actual, Object... expected) {
972        MoreAsserts.assertContentsInOrder(null, actual, expected);
973    }
974
975    void assertExtra(String message, String extraColumn, Object extraValue) {
976        SuggestionData s = sourceSuggestion(APP_SOURCE);
977        s.setExtras(new MockSuggestionExtras().put(extraColumn, extraValue));
978        reportClick("a", s);
979        assertShortcutExtra(message, "a", extraColumn, extraValue);
980    }
981
982    void assertShortcutExtra(String message, String query, String extraColumn, Object extraValue) {
983        SuggestionCursor cursor = getShortcuts(query, mAllowedCorpora);
984        try {
985            SuggestionCursorUtil.assertSuggestionExtras(message, cursor, extraColumn, extraValue);
986        } finally {
987            if (cursor != null) cursor.close();
988        }
989    }
990
991}
992