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