package com.airbnb.epoxy; import com.google.common.collect.Collections2; import junit.framework.Assert; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Random; import static com.airbnb.epoxy.ModelTestUtils.addModels; import static com.airbnb.epoxy.ModelTestUtils.changeValues; import static com.airbnb.epoxy.ModelTestUtils.convertToTestModels; import static com.airbnb.epoxy.ModelTestUtils.remove; import static com.airbnb.epoxy.ModelTestUtils.removeModelsAfterPosition; import static junit.framework.Assert.assertEquals; @Config(sdk = 21, manifest = TestRunner.MANIFEST_PATH) @RunWith(TestRunner.class) public class DifferCorrectnessTest { private static final boolean SHOW_LOGS = false; /** * If true, will log the time taken on the diff and skip the validation since that takes a long * time for big change sets. */ private static final boolean SPEED_RUN = false; private final TestObserver testObserver = new TestObserver(SHOW_LOGS); private final TestAdapter testAdapter = new TestAdapter(); private final List<EpoxyModel<?>> models = testAdapter.models; private static long totalDiffMillis = 0; private static long totalDiffOperations = 0; private static long totalDiffs = 0; @BeforeClass public static void beforeClass() { totalDiffMillis = 0; totalDiffOperations = 0; totalDiffs = 0; } @AfterClass public static void afterClass() { if (SPEED_RUN) { System.out.println("Total time for all diffs (ms): " + totalDiffMillis); } else { System.out.println("Total operations for diffs: " + totalDiffOperations); double avgOperations = ((double) totalDiffOperations / totalDiffs); System.out.println("Average operations per diff: " + avgOperations); } } @Before public void setUp() { if (!SPEED_RUN) { testAdapter.registerAdapterDataObserver(testObserver); } } @Test public void noChange() { diffAndValidateWithOpCount(0); } @Test public void simpleUpdate() { addModels(models); diffAndValidate(); changeValues(models); diffAndValidateWithOpCount(1); } @Test public void updateStart() { addModels(models); diffAndValidate(); changeValues(models, 0, models.size() / 2); diffAndValidateWithOpCount(1); } @Test public void updateMiddle() { addModels(models); diffAndValidate(); changeValues(models, models.size() / 3, models.size() * 2 / 3); diffAndValidateWithOpCount(1); } @Test public void updateEnd() { addModels(models); diffAndValidate(); changeValues(models, models.size() / 2, models.size()); diffAndValidateWithOpCount(1); } @Test public void shuffle() { // Tries all permutations of item shuffles, with various list sizes. Also randomizes // item values so that the diff must deal with both item updates and movements for (int i = 0; i < 9; i++) { List<EpoxyModel<?>> originalModels = new ArrayList<>(); addModels(i, originalModels); int permutationNumber = 0; for (List<EpoxyModel<?>> permutedModels : Collections2.permutations(originalModels)) { permutationNumber++; // Resetting to the original models each time, otherwise each subsequent permutation is // only a small difference models.clear(); models.addAll(originalModels); diffAndValidate(); models.clear(); models.addAll(permutedModels); changeValues(models); log("\n\n***** Permutation " + permutationNumber + " - List Size: " + i + " ****** \n"); log("old models:\n" + models); log("\n"); log("new models:\n" + models); log("\n"); diffAndValidate(); } } } @Test public void swapEnds() { addModels(models); diffAndValidate(); EpoxyModel<?> firstModel = models.remove(0); EpoxyModel<?> lastModel = models.remove(models.size() - 1); models.add(0, lastModel); models.add(firstModel); diffAndValidateWithOpCount(2); } @Test public void moveFrontToEnd() { addModels(models); diffAndValidate(); EpoxyModel<?> firstModel = models.remove(0); models.add(firstModel); diffAndValidateWithOpCount(1); } @Test public void moveEndToFront() { addModels(models); diffAndValidate(); EpoxyModel<?> lastModel = models.remove(models.size() - 1); models.add(0, lastModel); diffAndValidateWithOpCount(1); } @Test public void moveEndToFrontAndChangeValues() { addModels(models); diffAndValidate(); EpoxyModel<?> lastModel = models.remove(models.size() - 1); models.add(0, lastModel); changeValues(models); diffAndValidateWithOpCount(2); } @Test public void swapHalf() { addModels(models); diffAndValidate(); List<EpoxyModel<?>> firstHalf = models.subList(0, models.size() / 2); ArrayList<EpoxyModel<?>> firstHalfCopy = new ArrayList<>(firstHalf); firstHalf.clear(); models.addAll(firstHalfCopy); diffAndValidateWithOpCount(firstHalfCopy.size()); } @Test public void reverse() { addModels(models); diffAndValidate(); Collections.reverse(models); diffAndValidate(); } @Test public void removeAll() { addModels(models); diffAndValidate(); models.clear(); diffAndValidateWithOpCount(1); } @Test public void removeEnd() { addModels(models); diffAndValidate(); int half = models.size() / 2; ModelTestUtils.remove(models, half, half); diffAndValidateWithOpCount(1); } @Test public void removeMiddle() { addModels(models); diffAndValidate(); int third = models.size() / 3; ModelTestUtils.remove(models, third, third); diffAndValidateWithOpCount(1); } @Test public void removeStart() { addModels(models); diffAndValidate(); int half = models.size() / 2; ModelTestUtils.remove(models, 0, half); diffAndValidateWithOpCount(1); } @Test public void multipleRemovals() { addModels(models); diffAndValidate(); int size = models.size(); int tenth = size / 10; // Remove a tenth of the models at the end, middle, and start ModelTestUtils.removeModelsAfterPosition(models, size - tenth); ModelTestUtils.remove(models, size / 2, tenth); ModelTestUtils.remove(models, 0, tenth); diffAndValidateWithOpCount(3); } @Test public void simpleAdd() { addModels(models); diffAndValidateWithOpCount(1); } @Test public void addToStart() { addModels(models); diffAndValidate(); addModels(models, 0); diffAndValidateWithOpCount(1); } @Test public void addToMiddle() { addModels(models); diffAndValidate(); addModels(models, models.size() / 2); diffAndValidateWithOpCount(1); } @Test public void addToEnd() { addModels(models); diffAndValidate(); addModels(models); diffAndValidateWithOpCount(1); } @Test public void multipleInsertions() { addModels(models); diffAndValidate(); addModels(models, 0); addModels(models, models.size() * 2 / 3); addModels(models); diffAndValidateWithOpCount(3); } @Test public void moveTwoInFrontOfInsertion() { addModels(4, models); diffAndValidate(); addModels(1, models, 0); EpoxyModel<?> lastModel = models.remove(models.size() - 1); models.add(0, lastModel); lastModel = models.remove(models.size() - 1); models.add(0, lastModel); diffAndValidate(); } @Test public void randomCombinations() { int maxBatchSize = 3; int maxModelCount = 10; int maxSeed = 100000; // This modifies the models list in a random way many times, with different size lists. for (int modelCount = 1; modelCount < maxModelCount; modelCount++) { for (int randomSeed = 0; randomSeed < maxSeed; randomSeed++) { log("\n\n*** Combination seed " + randomSeed + " Model Count: " + modelCount + " *** \n"); // We keep the list from the previous loop and keep modifying it. This allows us to test // that state is maintained properly between diffs. We just make sure the list size // says the same by adding or removing if necessary int currentModelCount = models.size(); if (currentModelCount < modelCount) { addModels(modelCount - currentModelCount, models); } else if (currentModelCount > modelCount) { removeModelsAfterPosition(models, modelCount); } diffAndValidate(); modifyModelsRandomly(models, maxBatchSize, new Random(randomSeed)); log("\nResulting diff: \n"); diffAndValidate(); } } } private void modifyModelsRandomly(List<EpoxyModel<?>> models, int maxBatchSize, Random random) { for (int i = 0; i < models.size(); i++) { int batchSize = randInt(1, maxBatchSize, random); switch (random.nextInt(4)) { case 0: // insert log("Inserting " + batchSize + " at " + i); addModels(batchSize, models, i); i += batchSize; break; case 1: // remove int numAvailableToRemove = models.size() - i; batchSize = numAvailableToRemove < batchSize ? numAvailableToRemove : batchSize; log("Removing " + batchSize + " at " + i); remove(models, i, batchSize); break; case 2: // change int numAvailableToChange = models.size() - i; batchSize = numAvailableToChange < batchSize ? numAvailableToChange : batchSize; log("Changing " + batchSize + " at " + i); changeValues(models, i, batchSize); break; case 3: // move int targetPosition = random.nextInt(models.size()); EpoxyModel<?> currentItem = models.remove(i); models.add(targetPosition, currentItem); log("Moving " + i + " to " + targetPosition); break; default: throw new IllegalStateException("unhandled)"); } } } private void diffAndValidate() { diffAndValidateWithOpCount(-1); } private void diffAndValidateWithOpCount(int expectedOperationCount) { testObserver.operationCount = 0; long start = System.currentTimeMillis(); testAdapter.notifyModelsChanged(); long end = System.currentTimeMillis(); totalDiffMillis += (end - start); totalDiffOperations += testObserver.operationCount; totalDiffs++; if (!SPEED_RUN) { if (expectedOperationCount != -1) { assertEquals("Operation count is incorrect", expectedOperationCount, testObserver.operationCount); } List<TestModel> newModels = convertToTestModels(models); checkDiff(testObserver.initialModels, testObserver.modelsAfterDiffing, newModels); testObserver.setUpForNextDiff(newModels); } } private static int randInt(int min, int max, Random rand) { // nextInt is normally exclusive of the top value, // so add 1 to make it inclusive return rand.nextInt((max - min) + 1) + min; } private void log(String text) { log(text, false); } private void log(String text, boolean forceShow) { if (forceShow || SHOW_LOGS) { System.out.println(text); } } private void checkDiff(List<TestModel> modelsBeforeDiff, List<TestModel> modelsAfterDiff, List<TestModel> actualModels) { assertEquals("Diff produces list of different size.", actualModels.size(), modelsAfterDiff.size()); for (int i = 0; i < modelsAfterDiff.size(); i++) { TestModel model = modelsAfterDiff.get(i); final TestModel expected = actualModels.get(i); if (model == InsertedModel.INSTANCE) { // If the item at this index is new then it shouldn't exist in the original list for (TestModel oldModel : modelsBeforeDiff) { Assert.assertNotSame("The inserted model should not exist in the original list", oldModel.id(), expected.id()); } } else { assertEquals("Models at same index should have same id", expected.id(), model.id()); if (model.updated) { // If there was a change operation then the item hashcodes should be different Assert .assertNotSame("Incorrectly updated an item.", model.hashCode(), expected.hashCode()); } else { assertEquals("Models should have same hashcode when not updated", expected.hashCode(), model.hashCode()); } // Clear state so the model can be used again in another diff model.updated = false; } } } }