| // Copyright 2015 The Bazel Authors. All rights reserved. | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| package com.google.devtools.build.android.ziputils; | |
| import static com.google.common.truth.Truth.assertThat; | |
| import static com.google.common.truth.Truth.assertWithMessage; | |
| import com.google.common.collect.Range; | |
| import java.util.ArrayList; | |
| import java.util.Arrays; | |
| import java.util.Collection; | |
| import java.util.LinkedHashMap; | |
| import java.util.List; | |
| import java.util.Map; | |
| import org.junit.Test; | |
| import org.junit.runner.RunWith; | |
| import org.junit.runners.JUnit4; | |
| /** | |
| * Unit tests for {@link Splitter}. | |
| */ | |
| @RunWith(JUnit4.class) | |
| public class SplitterTest { | |
| private static final String ARCHIVE_DIR_SUFFIX = "/"; | |
| private static final String ARCHIVE_FILE_SEPARATOR = "/"; | |
| private static final String CLASS_SUFFIX = ".class"; | |
| @Test | |
| public void testAssign() { | |
| int size = 10; | |
| Collection<String> input; | |
| ArrayList<String> filter; | |
| Map<String, Integer> output; | |
| input = genEntries(size, size); | |
| filter = new ArrayList<>(10); | |
| for (int i = 0; i < size; i++) { | |
| filter.add("dir" + i + ARCHIVE_FILE_SEPARATOR + "file" + i + CLASS_SUFFIX); | |
| } | |
| Splitter splitter = new Splitter(size + 1, input.size()); | |
| splitter.assign(filter); | |
| splitter.nextShard(); | |
| output = new LinkedHashMap<>(); | |
| for (String path : input) { | |
| output.put(path, splitter.assign(path)); | |
| } | |
| for (int i = 0; i < size; i++) { | |
| for (int j = 0; j < size; j++) { | |
| String path = "dir" + i + ARCHIVE_FILE_SEPARATOR + "file" + j + CLASS_SUFFIX; | |
| if (i == j) { | |
| assertWithMessage(path).that(output.get(path)).isEqualTo(0); | |
| } else { | |
| assertWithMessage(path).that(output.get(path)).isEqualTo(i + 1); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Test splitting of single-ile packages. Note, this is also testing for the situation | |
| * where input entries are unordered, and thus appearing to be in different packages, | |
| * to the implementation that only confiders the previous file to determine package | |
| * boundaries. | |
| */ | |
| @Test | |
| public void testSingleFilePackages() { | |
| int[][] params = { | |
| { 1, 1, 1}, // one shard, for one package with one file | |
| {1, 2, 1}, // one shard, for two packages, with one file each | |
| {1, 10, 1}, // one shard, for ten packages, with one file each | |
| {2, 2, 1}, // ... | |
| {2, 10, 1}, | |
| {2, 100, 1}, | |
| {10, 10, 1}, | |
| {10, 15, 1}, | |
| {10, 95, 1}, | |
| {97, 10000, 1}, | |
| }; | |
| comboRunner(params); | |
| } | |
| /** | |
| * Test cases where the number of shards is less than the number | |
| * of packages. This implies that the package size is less than | |
| * the average shard size. We expect shards to be multiple of | |
| * package size. | |
| */ | |
| @Test | |
| public void testPackageSplit() { | |
| int[][] params = { | |
| {2, 3, 2}, // two shards, for three packages, with two files each | |
| {2, 3, 9}, // ... | |
| {2, 3, 10}, | |
| {2, 3, 11}, | |
| {2, 3, 19}, | |
| {2, 10, 2}, | |
| {2, 10, 9}, | |
| {2, 10, 10}, | |
| {2, 10, 11}, | |
| {2, 10, 19}, | |
| {10, 11, 2}, | |
| {10, 11, 9}, | |
| {10, 11, 10}, | |
| {10, 11, 11}, | |
| {10, 11, 19}, | |
| {10, 111, 2}, | |
| {10, 111, 9}, | |
| {10, 111, 10}, | |
| {10, 111, 11}, | |
| {10, 111, 19}, | |
| {25, 1000, 8}, | |
| {25, 1000, 10}, | |
| {25, 1000, 19}, | |
| {250, 10000, 19}, | |
| }; | |
| comboRunner(params); | |
| } | |
| /** | |
| * Tests situations where the number of shards exceeds the number of | |
| * packages (but not the number of files). That is, the implementation | |
| * must split at least one package. | |
| */ | |
| @Test | |
| public void testForceSplit() { | |
| int[][] params = { | |
| {2, 1, 2}, // two shards, for one package, with two files | |
| {2, 1, 9}, // ... | |
| {2, 1, 10}, | |
| {2, 1, 11}, | |
| {3, 2, 2}, | |
| {10, 9, 2}, | |
| {10, 2, 9}, | |
| {10, 9, 9}, | |
| {10, 2, 10}, | |
| {10, 9, 10}, | |
| {10, 2, 11}, | |
| {10, 9, 11}, | |
| {10, 2, 111}, | |
| {10, 9, 111}, | |
| {100, 12, 9}, | |
| {100, 12, 9}, | |
| {100, 10, 10}, | |
| {100, 10, 10}, | |
| {100, 10, 11}, | |
| {100, 20, 111}, | |
| }; | |
| comboRunner(params); | |
| } | |
| /** | |
| * Tests situation where the number of shards requested exceeds the | |
| * the number of files. Empty shards are expected. | |
| */ | |
| @Test | |
| public void testEmptyShards() { | |
| int[][] params = { | |
| {2, 1, 1}, // two shards, for one package, with one files | |
| {10, 2, 2}, | |
| {100, 10, 9}, | |
| {100, 9, 10}, | |
| }; | |
| comboRunner(params); | |
| } | |
| /** | |
| * Run multiple test for sets of test specifications consisting of | |
| * "number of shards", "number of packages", "package size". | |
| */ | |
| private void comboRunner(int[][] params) { | |
| Collection<String> input; | |
| Map<String, Integer> output; | |
| for (int[] param : params) { | |
| input = genEntries(param[1], param[2]); | |
| output = runOne(param[0], input); | |
| String name = Arrays.toString(param); | |
| splitAsserts(name, param[0], param[1], param[2], | |
| commonAsserts(name, param[0], param[1], param[2], input, output)); | |
| } | |
| } | |
| private Map<String, Integer> runOne(int shards, Collection<String> entries) { | |
| Splitter splitter = new Splitter(shards, entries.size()); | |
| Map<String, Integer> result = new LinkedHashMap<>(); | |
| for (String entry : entries) { | |
| result.put(entry, splitter.assign(entry)); | |
| } | |
| return result; | |
| } | |
| private Collection<String> genEntries(int packages, int files) { | |
| List<String> entries = new ArrayList<>(); | |
| for (int dir = 0; dir < packages; dir++) { | |
| for (int file = 0; file < files; file++) { | |
| entries.add("dir" + dir + ARCHIVE_FILE_SEPARATOR + "file" + file + CLASS_SUFFIX); | |
| } | |
| } | |
| return entries; | |
| } | |
| private int[] assertAndCountMappings(int shards, int packageSize, | |
| Map<String, Integer> output, boolean expectPackageBoundaryShards) { | |
| int[] counts = new int[shards + 1]; | |
| String prevPath = null; | |
| int prev = -2; | |
| for (Map.Entry<String, Integer> entry : output.entrySet()) { | |
| String path = entry.getKey(); | |
| int assignment = entry.getValue(); | |
| assertThat(assignment).isIn(Range.closed(0, shards)); | |
| counts[assignment + 1]++; | |
| if (path.endsWith(CLASS_SUFFIX)) { | |
| if (prev == -2) { | |
| assertThat(assignment).isEqualTo(0); | |
| } else if (prev > 0 && prev != assignment) { | |
| assertThat(assignment).isEqualTo(prev + 1); // shard index increasing | |
| if (expectPackageBoundaryShards) { | |
| String prevDir = prevPath.substring(0, prevPath.lastIndexOf(ARCHIVE_DIR_SUFFIX)); | |
| String dir = path.substring(0, path.lastIndexOf(ARCHIVE_DIR_SUFFIX)); | |
| // package boundary, or full packages | |
| assertThat(!prevDir.equals(dir) || counts[prev + 1] % packageSize != 0).isTrue(); | |
| } | |
| } | |
| prevPath = path; | |
| } | |
| prev = assignment; | |
| } | |
| return counts; | |
| } | |
| /** | |
| * Validate that generated mapping maintains input order. | |
| */ | |
| private void assertMaintainOrder(Collection<String> input, Map<String, Integer> output) { | |
| assertThat(output.keySet()).containsExactlyElementsIn(input).inOrder(); | |
| } | |
| /** | |
| * Verifies that packages have not been unnecessarily split. | |
| */ | |
| private void assertNoSplit(String name, int packageSize, int[] counts) { | |
| for (int i = 1; i < counts.length; i++) { | |
| assertWithMessage(name + " shard " + i).that(counts[i]).isAtLeast(0); | |
| } | |
| } | |
| /** | |
| * Verifies the presence of package-split in the tailing shards. | |
| */ | |
| private void assertHasSplit(String name, int packageSize, int[] counts) { | |
| for (int i = 1; i < counts.length - 1; i++) { | |
| if (counts[i + 1] <= 1) { | |
| continue; | |
| } | |
| assertWithMessage(name + " shard " + i).that(counts[i]).isAtMost(packageSize); | |
| } | |
| } | |
| /** | |
| * Verify the presence of tailing empty shards, if unavoidable. | |
| */ | |
| private void assertHasEmpty(String name, int[] counts, boolean expectEmpty) { | |
| boolean hasEmpty = false; | |
| for (int i = 1; i < counts.length; i++) { | |
| if (counts[i] == 0) { | |
| hasEmpty = true; | |
| } else { | |
| assertThat(!hasEmpty || counts[i] == 0).isTrue(); | |
| } | |
| } | |
| assertWithMessage(name).that(hasEmpty).isEqualTo(expectEmpty); | |
| } | |
| /** | |
| * Validates that each shard meets expected minimal and maximum size requirements, | |
| * to ensure that shards are reasonably evenly sized. | |
| */ | |
| private void assertBalanced(String name, int shards, int packageCount, int packageSize, | |
| int entries, int[] counts) { | |
| int classes = packageSize * packageCount; | |
| int noneClass = entries - counts[0] - classes; | |
| int idealSize = Math.max(1, classes / shards); | |
| int delta = Math.min(Math.min(10, (idealSize + 3) >> 2), (int) Math.log(shards)); | |
| int lowerBound = idealSize - delta; | |
| int upperBound = idealSize + delta; | |
| for (int i = 1; i < counts.length; i++) { | |
| int adjusted = i == 1 ? counts[i] - noneClass : counts[i]; | |
| if (i < shards && counts[i + 1] > 1) { | |
| if (shards <= packageCount) { | |
| // if there are fewer shards than packages, expect shards contain at least 1 full package | |
| assertWithMessage(name + " dense shard " + i) | |
| .that(counts[i]) | |
| .isIn(Range.closed(packageSize, entries)); | |
| } else { | |
| assertWithMessage(name + " sparse shard " + i) | |
| .that(counts[i]) | |
| .isIn(Range.closed(0, packageSize)); | |
| } | |
| if (noneClass == 0 && counts[0] == 0) { | |
| // Give some slack in minimal number of entries in a shard because Splitter recomputes | |
| // boundaries for each shard, so our computed bounds can be off for later shards. | |
| assertWithMessage(name + " shard " + i) | |
| .that(counts[i]) | |
| .isIn(Range.closed(lowerBound - i, entries)); | |
| } | |
| } | |
| // Give some slack in maximum number of entries in a shard because Splitter recomputes | |
| // boundaries for each shard, so our computed bounds can be off for later shards. | |
| assertWithMessage(name + " shard " + i).that(adjusted).isAtMost(upperBound + i); | |
| } | |
| } | |
| /** | |
| * Verifies that packages are only split as expected, and that no unexpected | |
| * empty shards are generated. | |
| */ | |
| private void splitAsserts(String name, int shards, int packageCount, int packageSize, | |
| int[] counts) { | |
| boolean emptyExpected = packageCount * packageSize < shards; | |
| boolean splitExpected = shards > packageCount; | |
| if (splitExpected) { | |
| assertHasSplit(name, packageSize, counts); | |
| } else { | |
| assertNoSplit(name, packageSize, counts); | |
| } | |
| assertHasEmpty(name, counts, emptyExpected); | |
| } | |
| /** | |
| * Checks assert applicable to all tests. | |
| */ | |
| private int[] commonAsserts(String name, int shards, int packageCount, int packageSize, | |
| Collection<String> input, Map<String, Integer> output) { | |
| assertMaintainOrder(input, output); | |
| int[] counts = assertAndCountMappings(shards, packageSize, output, packageCount <= shards); | |
| assertBalanced(name, shards, packageCount, packageSize, input.size(), counts); | |
| return counts; | |
| } | |
| } |