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