1/*
2 * Copyright (C) 2015 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 */
16
17package com.android.tv.recommendation;
18
19import android.test.AndroidTestCase;
20import android.test.MoreAsserts;
21import android.test.suitebuilder.annotation.SmallTest;
22
23import com.android.tv.data.Channel;
24import com.android.tv.recommendation.RecommendationUtils.ChannelRecordSortedMapHelper;
25import com.android.tv.testing.Utils;
26
27import java.util.ArrayList;
28import java.util.Arrays;
29import java.util.Collections;
30import java.util.Comparator;
31import java.util.HashMap;
32import java.util.List;
33import java.util.Map;
34import java.util.concurrent.TimeUnit;
35
36@SmallTest
37public class RecommenderTest extends AndroidTestCase {
38    private static final int DEFAULT_NUMBER_OF_CHANNELS = 5;
39    private static final long DEFAULT_WATCH_START_TIME_MS =
40            System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2);
41    private static final long DEFAULT_WATCH_END_TIME_MS =
42            System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1);
43    private static final long DEFAULT_MAX_WATCH_DURATION_MS = TimeUnit.HOURS.toMillis(1);
44
45    private final Comparator<Channel> CHANNEL_SORT_KEY_COMPARATOR = new Comparator<Channel>() {
46        @Override
47        public int compare(Channel lhs, Channel rhs) {
48            return mRecommender.getChannelSortKey(lhs.getId())
49                    .compareTo(mRecommender.getChannelSortKey(rhs.getId()));
50        }
51    };
52    private final Runnable START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS = new Runnable() {
53        @Override
54        public void run() {
55            // Add 4 channels in ChannelRecordMap for testing. Store the added channels to
56            // mChannels_1 ~ mChannels_4. They are sorted by channel id in increasing order.
57            mChannel_1 = mChannelRecordSortedMap.addChannel();
58            mChannel_2 = mChannelRecordSortedMap.addChannel();
59            mChannel_3 = mChannelRecordSortedMap.addChannel();
60            mChannel_4 = mChannelRecordSortedMap.addChannel();
61        }
62    };
63
64    private RecommendationDataManager mDataManager;
65    private Recommender mRecommender;
66    private FakeEvaluator mEvaluator;
67    private ChannelRecordSortedMapHelper mChannelRecordSortedMap;
68    private boolean mOnRecommenderReady;
69    private boolean mOnRecommendationChanged;
70    private Channel mChannel_1;
71    private Channel mChannel_2;
72    private Channel mChannel_3;
73    private Channel mChannel_4;
74
75    @Override
76    public void setUp() throws Exception {
77        super.setUp();
78
79        mChannelRecordSortedMap = new ChannelRecordSortedMapHelper(getContext());
80        mDataManager = RecommendationUtils
81                .createMockRecommendationDataManager(mChannelRecordSortedMap);
82        mChannelRecordSortedMap.resetRandom(Utils.createTestRandom());
83    }
84
85    public void testRecommendChannels_includeRecommendedOnly_allChannelsHaveNoScore() {
86        createRecommender(true, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
87
88        // Recommender doesn't recommend any channels because all channels are not recommended.
89        assertEquals(0, mRecommender.recommendChannels().size());
90        assertEquals(0, mRecommender.recommendChannels(-5).size());
91        assertEquals(0, mRecommender.recommendChannels(0).size());
92        assertEquals(0, mRecommender.recommendChannels(3).size());
93        assertEquals(0, mRecommender.recommendChannels(4).size());
94        assertEquals(0, mRecommender.recommendChannels(5).size());
95    }
96
97    public void testRecommendChannels_notIncludeRecommendedOnly_allChannelsHaveNoScore() {
98        createRecommender(false, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
99
100        // Recommender recommends every channel because it recommends not-recommended channels too.
101        assertEquals(4, mRecommender.recommendChannels().size());
102        assertEquals(0, mRecommender.recommendChannels(-5).size());
103        assertEquals(0, mRecommender.recommendChannels(0).size());
104        assertEquals(3, mRecommender.recommendChannels(3).size());
105        assertEquals(4, mRecommender.recommendChannels(4).size());
106        assertEquals(4, mRecommender.recommendChannels(5).size());
107    }
108
109    public void testRecommendChannels_includeRecommendedOnly_allChannelsHaveScore() {
110        createRecommender(true, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
111
112        setChannelScores_scoreIncreasesAsChannelIdIncreases();
113
114        // recommendChannels must be sorted by score in decreasing order.
115        // (i.e. sorted by channel ID in decreasing order in this case)
116        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(),
117                mChannel_4, mChannel_3, mChannel_2, mChannel_1);
118        assertEquals(0, mRecommender.recommendChannels(-5).size());
119        assertEquals(0, mRecommender.recommendChannels(0).size());
120        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(3),
121                mChannel_4, mChannel_3, mChannel_2);
122        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(4),
123                mChannel_4, mChannel_3, mChannel_2, mChannel_1);
124        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(5),
125                mChannel_4, mChannel_3, mChannel_2, mChannel_1);
126    }
127
128    public void testRecommendChannels_notIncludeRecommendedOnly_allChannelsHaveScore() {
129        createRecommender(false, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
130
131        setChannelScores_scoreIncreasesAsChannelIdIncreases();
132
133        // recommendChannels must be sorted by score in decreasing order.
134        // (i.e. sorted by channel ID in decreasing order in this case)
135        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(),
136                mChannel_4, mChannel_3, mChannel_2, mChannel_1);
137        assertEquals(0, mRecommender.recommendChannels(-5).size());
138        assertEquals(0, mRecommender.recommendChannels(0).size());
139        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(3),
140                mChannel_4, mChannel_3, mChannel_2);
141        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(4),
142                mChannel_4, mChannel_3, mChannel_2, mChannel_1);
143        MoreAsserts.assertContentsInOrder(mRecommender.recommendChannels(5),
144                mChannel_4, mChannel_3, mChannel_2, mChannel_1);
145    }
146
147    public void testRecommendChannels_includeRecommendedOnly_fewChannelsHaveScore() {
148        createRecommender(true, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
149
150        mEvaluator.setChannelScore(mChannel_1.getId(), 1.0);
151        mEvaluator.setChannelScore(mChannel_2.getId(), 1.0);
152
153        // Only two channels are recommended because recommender doesn't recommend other channels.
154        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels(),
155                mChannel_1, mChannel_2);
156        assertEquals(0, mRecommender.recommendChannels(-5).size());
157        assertEquals(0, mRecommender.recommendChannels(0).size());
158        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels(3),
159                mChannel_1, mChannel_2);
160        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels(4),
161                mChannel_1, mChannel_2);
162        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels(5),
163                mChannel_1, mChannel_2);
164    }
165
166    public void testRecommendChannels_notIncludeRecommendedOnly_fewChannelsHaveScore() {
167        createRecommender(false, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
168
169        mEvaluator.setChannelScore(mChannel_1.getId(), 1.0);
170        mEvaluator.setChannelScore(mChannel_2.getId(), 1.0);
171
172        assertEquals(4, mRecommender.recommendChannels().size());
173        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels().subList(0, 2),
174                mChannel_1, mChannel_2);
175
176        assertEquals(0, mRecommender.recommendChannels(-5).size());
177        assertEquals(0, mRecommender.recommendChannels(0).size());
178
179        assertEquals(3, mRecommender.recommendChannels(3).size());
180        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels(3).subList(0, 2),
181                mChannel_1, mChannel_2);
182
183        assertEquals(4, mRecommender.recommendChannels(4).size());
184        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels(4).subList(0, 2),
185                mChannel_1, mChannel_2);
186
187        assertEquals(4, mRecommender.recommendChannels(5).size());
188        MoreAsserts.assertContentsInAnyOrder(mRecommender.recommendChannels(5).subList(0, 2),
189                mChannel_1, mChannel_2);
190    }
191
192    public void testGetChannelSortKey_recommendAllChannels() {
193        createRecommender(true, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
194
195        setChannelScores_scoreIncreasesAsChannelIdIncreases();
196
197        List<Channel> expectedChannelList = mRecommender.recommendChannels();
198        List<Channel> channelList = Arrays.asList(mChannel_1, mChannel_2, mChannel_3, mChannel_4);
199        Collections.sort(channelList, CHANNEL_SORT_KEY_COMPARATOR);
200
201        // Recommended channel list and channel list sorted by sort key must be the same.
202        MoreAsserts.assertContentsInOrder(channelList, expectedChannelList.toArray());
203        assertSortKeyNotInvalid(channelList);
204    }
205
206    public void testGetChannelSortKey_recommendFewChannels() {
207        // Test with recommending 3 channels.
208        createRecommender(true, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
209
210        setChannelScores_scoreIncreasesAsChannelIdIncreases();
211
212        List<Channel> expectedChannelList = mRecommender.recommendChannels(3);
213        // A channel which is not recommended by the recommender has to get an invalid sort key.
214        assertEquals(Recommender.INVALID_CHANNEL_SORT_KEY,
215                mRecommender.getChannelSortKey(mChannel_1.getId()));
216
217        List<Channel> channelList = Arrays.asList(mChannel_2, mChannel_3, mChannel_4);
218        Collections.sort(channelList, CHANNEL_SORT_KEY_COMPARATOR);
219
220        MoreAsserts.assertContentsInOrder(channelList, expectedChannelList.toArray());
221        assertSortKeyNotInvalid(channelList);
222    }
223
224    public void testListener_onRecommendationChanged() {
225        createRecommender(true, START_DATAMANAGER_RUNNABLE_ADD_FOUR_CHANNELS);
226        // FakeEvaluator doesn't recommend a channel with empty watch log. As every channel
227        // doesn't have a watch log, nothing is recommended and recommendation isn't changed.
228        assertFalse(mOnRecommendationChanged);
229
230        // Set lastRecommendationUpdatedTimeUtcMs to check recommendation changed because,
231        // recommender has a minimum recommendation update period.
232        mRecommender.setLastRecommendationUpdatedTimeUtcMs(
233                System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(10));
234        long latestWatchEndTimeMs = DEFAULT_WATCH_START_TIME_MS;
235        for (long channelId : mChannelRecordSortedMap.keySet()) {
236            mEvaluator.setChannelScore(channelId, 1.0);
237            // Add a log to recalculate the recommendation score.
238            assertTrue(mChannelRecordSortedMap.addWatchLog(channelId, latestWatchEndTimeMs,
239                    TimeUnit.MINUTES.toMillis(10)));
240            latestWatchEndTimeMs += TimeUnit.MINUTES.toMillis(10);
241        }
242
243        // onRecommendationChanged must be called, because recommend channels are not empty,
244        // by setting score to each channel.
245        assertTrue(mOnRecommendationChanged);
246    }
247
248    public void testListener_onRecommenderReady() {
249        createRecommender(true, new Runnable() {
250            @Override
251            public void run() {
252                mChannelRecordSortedMap.addChannels(DEFAULT_NUMBER_OF_CHANNELS);
253                mChannelRecordSortedMap.addRandomWatchLogs(DEFAULT_WATCH_START_TIME_MS,
254                        DEFAULT_WATCH_END_TIME_MS, DEFAULT_MAX_WATCH_DURATION_MS);
255            }
256        });
257
258        // After loading channels and watch logs are finished, recommender must be available to use.
259        assertTrue(mOnRecommenderReady);
260    }
261
262    private void assertSortKeyNotInvalid(List<Channel> channelList) {
263        for (Channel channel : channelList) {
264            MoreAsserts.assertNotEqual(Recommender.INVALID_CHANNEL_SORT_KEY,
265                    mRecommender.getChannelSortKey(channel.getId()));
266        }
267    }
268
269    private void createRecommender(boolean includeRecommendedOnly,
270            Runnable startDataManagerRunnable) {
271        mRecommender = new Recommender(new Recommender.Listener() {
272            @Override
273            public void onRecommenderReady() {
274                mOnRecommenderReady = true;
275            }
276            @Override
277            public void onRecommendationChanged() {
278                mOnRecommendationChanged = true;
279            }
280        }, includeRecommendedOnly, mDataManager);
281
282        mEvaluator = new FakeEvaluator();
283        mRecommender.registerEvaluator(mEvaluator);
284        mChannelRecordSortedMap.setRecommender(mRecommender);
285
286        // When mRecommender is instantiated, its dataManager will be started, and load channels
287        // and watch history data if it is not started.
288        if (startDataManagerRunnable != null) {
289            startDataManagerRunnable.run();
290            mRecommender.onChannelRecordChanged();
291        }
292        // After loading channels and watch history data are finished,
293        // RecommendationDataManager calls listener.onChannelRecordLoaded()
294        // which will be mRecommender.onChannelRecordLoaded().
295        mRecommender.onChannelRecordLoaded();
296    }
297
298    private List<Long> getChannelIdListSorted() {
299        return new ArrayList<>(mChannelRecordSortedMap.keySet());
300    }
301
302    private void setChannelScores_scoreIncreasesAsChannelIdIncreases() {
303        List<Long> channelIdList = getChannelIdListSorted();
304        double score = Math.pow(0.5, channelIdList.size());
305        for (long channelId : channelIdList) {
306            // Channel with smaller id has smaller score than channel with higher id.
307            mEvaluator.setChannelScore(channelId, score);
308            score *= 2.0;
309        }
310    }
311
312    private class FakeEvaluator extends Recommender.Evaluator {
313        private final Map<Long, Double> mChannelScore = new HashMap<>();
314
315        @Override
316        public double evaluateChannel(long channelId) {
317            if (getRecommender().getChannelRecord(channelId) == null) {
318                return NOT_RECOMMENDED;
319            }
320            Double score = mChannelScore.get(channelId);
321            return score == null ? NOT_RECOMMENDED : score;
322        }
323
324        public void setChannelScore(long channelId, double score) {
325            mChannelScore.put(channelId, score);
326        }
327    }
328}
329