1 // Copyright 2023 The Chromium Authors 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.base.test.util; 6 7 import static org.junit.Assert.fail; 8 9 import androidx.annotation.Nullable; 10 11 import com.google.common.collect.Iterators; 12 import com.google.common.collect.PeekingIterator; 13 14 import org.chromium.base.metrics.HistogramBucket; 15 import org.chromium.base.metrics.RecordHistogram; 16 17 import java.util.ArrayList; 18 import java.util.HashMap; 19 import java.util.HashSet; 20 import java.util.Iterator; 21 import java.util.List; 22 import java.util.Map; 23 import java.util.Map.Entry; 24 import java.util.Objects; 25 import java.util.Set; 26 import java.util.TreeMap; 27 28 /** 29 * Watches a number of histograms in tests to assert later that the expected values were recorded. 30 * 31 * Uses the delta of records between build() and assertExpected(), so that records logged in 32 * previous tests (in batched tests) don't interfere with the counting as would happen with direct 33 * calls to {@link RecordHistogram}. 34 * 35 * Usage: 36 * 37 * // Arrange 38 * var histogramWatcher = HistogramWatcher.newBuilder() 39 * .expectIntRecord("Histogram1", 555) 40 * .expectIntRecord("Histogram1", 666) 41 * .expectBooleanRecord("Histogram2", true) 42 * .expectAnyRecord("Histogram3") 43 * .build(); 44 * or: 45 * var histogramWatcher = HistogramWatcher.newSingleRecordWatcher("Histogram1", 555); 46 * 47 * // Act 48 * [code under test that is expected to record the histograms above] 49 * 50 * // Assert 51 * histogramWatcher.assertExpected(); 52 * 53 * Alternatively, Java's try-with-resources can be used to wrap the act block to make the assert 54 * implicit. This can be especially helpful when a test case needs to create multiple watchers, 55 * as the watcher variables are scoped separately and cannot be accidentally swapped. 56 * 57 * try (HistogramWatcher ignored = HistogramWatcher.newSingleRecordWatcher("Histogram1") { 58 * [code under test that is expected to record the histogram above] 59 * } 60 */ 61 public class HistogramWatcher implements AutoCloseable { 62 /** Create a new {@link HistogramWatcher.Builder} to instantiate {@link HistogramWatcher}. */ newBuilder()63 public static HistogramWatcher.Builder newBuilder() { 64 return new HistogramWatcher.Builder(); 65 } 66 67 /** 68 * Convenience method to create a new {@link HistogramWatcher} that expects a single boolean 69 * record with {@code value} for {@code histogram} and no more records to the same histogram. 70 */ newSingleRecordWatcher(String histogram, boolean value)71 public static HistogramWatcher newSingleRecordWatcher(String histogram, boolean value) { 72 return newBuilder().expectBooleanRecord(histogram, value).build(); 73 } 74 75 /** 76 * Convenience method to create a new {@link HistogramWatcher} that expects a single integer 77 * record with {@code value} for {@code histogram} and no more records to the same histogram. 78 */ newSingleRecordWatcher(String histogram, int value)79 public static HistogramWatcher newSingleRecordWatcher(String histogram, int value) { 80 return newBuilder().expectIntRecord(histogram, value).build(); 81 } 82 83 /** 84 * Convenience method to create a new {@link HistogramWatcher} that expects a single record with 85 * any value for {@code histogram} and no more records to the same histogram. 86 */ newSingleRecordWatcher(String histogram)87 public static HistogramWatcher newSingleRecordWatcher(String histogram) { 88 return newBuilder().expectAnyRecord(histogram).build(); 89 } 90 91 /** Builder for {@link HistogramWatcher}. Use to list the expectations of records. */ 92 public static class Builder { 93 private final Map<HistogramAndValue, Integer> mRecordsExpected = new HashMap<>(); 94 private final Map<String, Integer> mTotalRecordsExpected = new HashMap<>(); 95 private final Set<String> mHistogramsAllowedExtraRecords = new HashSet<>(); 96 97 /** Use {@link HistogramWatcher#newBuilder()} to instantiate. */ Builder()98 private Builder() {} 99 100 /** 101 * Build the {@link HistogramWatcher} and snapshot current number of records of the expected 102 * histograms to calculate the delta later. 103 */ build()104 public HistogramWatcher build() { 105 return new HistogramWatcher( 106 mRecordsExpected, 107 mTotalRecordsExpected.keySet(), 108 mHistogramsAllowedExtraRecords); 109 } 110 111 /** 112 * Add an expectation that {@code histogram} will be recorded once with a boolean {@code 113 * value}. 114 */ expectBooleanRecord(String histogram, boolean value)115 public Builder expectBooleanRecord(String histogram, boolean value) { 116 return expectBooleanRecordTimes(histogram, value, 1); 117 } 118 119 /** 120 * Add an expectation that {@code histogram} will be recorded a number of {@code times} with 121 * a boolean {@code value}. 122 */ expectBooleanRecordTimes(String histogram, boolean value, int times)123 public Builder expectBooleanRecordTimes(String histogram, boolean value, int times) { 124 return expectIntRecordTimes(histogram, value ? 1 : 0, times); 125 } 126 127 /** 128 * Add an expectation that {@code histogram} will be recorded once with an int {@code 129 * value}. 130 */ expectIntRecord(String histogram, int value)131 public Builder expectIntRecord(String histogram, int value) { 132 return expectIntRecordTimes(histogram, value, 1); 133 } 134 135 /** 136 * Add expectations that {@code histogram} will be recorded with each of the int {@code 137 * values} provided. 138 */ expectIntRecords(String histogram, int... values)139 public Builder expectIntRecords(String histogram, int... values) { 140 for (int value : values) { 141 expectIntRecord(histogram, value); 142 } 143 return this; 144 } 145 146 /** 147 * Add an expectation that {@code histogram} will be recorded a number of {@code times} with 148 * an int {@code value}. 149 */ expectIntRecordTimes(String histogram, int value, int times)150 public Builder expectIntRecordTimes(String histogram, int value, int times) { 151 if (times < 0) { 152 throw new IllegalArgumentException( 153 "Cannot expect records a negative number of times"); 154 } else if (times == 0) { 155 throw new IllegalArgumentException( 156 "Cannot expect records zero times. Use expectNoRecords() if no records are" 157 + " expected for this histogram. If only certain values are expected" 158 + " for this histogram, by default extra records will already raise an" 159 + " assert."); 160 } 161 HistogramAndValue histogramAndValue = new HistogramAndValue(histogram, value); 162 incrementRecordsExpected(histogramAndValue, times); 163 incrementTotalRecordsExpected(histogram, times); 164 return this; 165 } 166 167 /** Add an expectation that {@code histogram} will be recorded once with any value. */ expectAnyRecord(String histogram)168 public Builder expectAnyRecord(String histogram) { 169 return expectAnyRecordTimes(histogram, 1); 170 } 171 172 /** 173 * Add an expectation that {@code histogram} will be recorded a number of {@code times} with 174 * any values. 175 */ expectAnyRecordTimes(String histogram, int times)176 public Builder expectAnyRecordTimes(String histogram, int times) { 177 HistogramAndValue histogramAndValue = new HistogramAndValue(histogram, ANY_VALUE); 178 incrementRecordsExpected(histogramAndValue, times); 179 incrementTotalRecordsExpected(histogram, times); 180 return this; 181 } 182 183 /** Add an expectation that {@code histogram} will not be recorded with any values. */ expectNoRecords(String histogram)184 public Builder expectNoRecords(String histogram) { 185 Integer recordsAlreadyExpected = mTotalRecordsExpected.get(histogram); 186 if (recordsAlreadyExpected != null && recordsAlreadyExpected != 0) { 187 throw new IllegalStateException( 188 "Cannot expect no records but also expect records in previous calls."); 189 } 190 191 mTotalRecordsExpected.put(histogram, 0); 192 return this; 193 } 194 195 /** 196 * Make more lenient the assert that records matched the expectations for {@code histogram} 197 * by ignoring extra records. 198 */ allowExtraRecords(String histogram)199 public Builder allowExtraRecords(String histogram) { 200 mHistogramsAllowedExtraRecords.add(histogram); 201 return this; 202 } 203 204 /** 205 * For all histograms with expectations added before, make more lenient the assert that 206 * records matched the expectations by ignoring extra records. 207 */ allowExtraRecordsForHistogramsAbove()208 public Builder allowExtraRecordsForHistogramsAbove() { 209 for (String histogram : mTotalRecordsExpected.keySet()) { 210 allowExtraRecords(histogram); 211 } 212 return this; 213 } 214 incrementRecordsExpected(HistogramAndValue histogramAndValue, int increase)215 private void incrementRecordsExpected(HistogramAndValue histogramAndValue, int increase) { 216 Integer previousCountExpected = mRecordsExpected.get(histogramAndValue); 217 if (previousCountExpected == null) { 218 previousCountExpected = 0; 219 } 220 mRecordsExpected.put(histogramAndValue, previousCountExpected + increase); 221 } 222 incrementTotalRecordsExpected(String histogram, int increase)223 private void incrementTotalRecordsExpected(String histogram, int increase) { 224 Integer previousCountExpected = mTotalRecordsExpected.get(histogram); 225 if (previousCountExpected == null) { 226 previousCountExpected = 0; 227 } 228 mTotalRecordsExpected.put(histogram, previousCountExpected + increase); 229 } 230 } 231 232 private static final int ANY_VALUE = -1; 233 234 private final Map<HistogramAndValue, Integer> mRecordsExpected; 235 private final Set<String> mHistogramsWatched; 236 private final Set<String> mHistogramsAllowedExtraRecords; 237 238 private final Map<String, List<HistogramBucket>> mStartingSamples = new HashMap<>(); 239 HistogramWatcher( Map<HistogramAndValue, Integer> recordsExpected, Set<String> histogramsWatched, Set<String> histogramsAllowedExtraRecords)240 private HistogramWatcher( 241 Map<HistogramAndValue, Integer> recordsExpected, 242 Set<String> histogramsWatched, 243 Set<String> histogramsAllowedExtraRecords) { 244 mRecordsExpected = recordsExpected; 245 mHistogramsWatched = histogramsWatched; 246 mHistogramsAllowedExtraRecords = histogramsAllowedExtraRecords; 247 248 takeSnapshot(); 249 } 250 takeSnapshot()251 private void takeSnapshot() { 252 for (String histogram : mHistogramsWatched) { 253 mStartingSamples.put( 254 histogram, RecordHistogram.getHistogramSamplesForTesting(histogram)); 255 } 256 } 257 258 /** 259 * Implements {@link AutoCloseable}. Note while this interface throws an {@link Exception}, we 260 * do not have to, and this allows call sites that know they're handling a 261 * {@link HistogramWatcher} to not catch or declare an exception either. 262 */ 263 @Override close()264 public void close() { 265 assertExpected(); 266 } 267 268 /** Assert that the watched histograms were recorded as expected. */ assertExpected()269 public void assertExpected() { 270 assertExpected(/* customMessage= */ null); 271 } 272 273 /** 274 * Assert that the watched histograms were recorded as expected, with a custom message if the 275 * assertion is not satisfied. 276 */ assertExpected(@ullable String customMessage)277 public void assertExpected(@Nullable String customMessage) { 278 for (String histogram : mHistogramsWatched) { 279 assertExpected(histogram, customMessage); 280 } 281 } 282 assertExpected(String histogram, @Nullable String customMessage)283 private void assertExpected(String histogram, @Nullable String customMessage) { 284 List<HistogramBucket> actualBuckets = computeActualBuckets(histogram); 285 TreeMap<Integer, Integer> expectedValuesAndCounts = new TreeMap<>(); 286 for (Entry<HistogramAndValue, Integer> kv : mRecordsExpected.entrySet()) { 287 if (kv.getKey().mHistogram.equals(histogram)) { 288 expectedValuesAndCounts.put(kv.getKey().mValue, kv.getValue()); 289 } 290 } 291 292 // Since |expectedValuesAndCounts| is a TreeMap, iterates expected records in ascending 293 // order by value. 294 Iterator<Entry<Integer, Integer>> expectedValuesAndCountsIt = 295 expectedValuesAndCounts.entrySet().iterator(); 296 Entry<Integer, Integer> expectedValueAndCount = 297 expectedValuesAndCountsIt.hasNext() ? expectedValuesAndCountsIt.next() : null; 298 if (expectedValueAndCount != null && expectedValueAndCount.getKey() == ANY_VALUE) { 299 // Skip the ANY_VALUE records expected - conveniently always the first entry -1 when 300 // present to check them differently at the end. 301 expectedValueAndCount = 302 expectedValuesAndCountsIt.hasNext() ? expectedValuesAndCountsIt.next() : null; 303 } 304 305 // Will match the actual records with the expected and flag |unexpected| when the actual 306 // records cannot match the expected, and count how many |actualExtraRecords| are seen to 307 // match them with |ANY_VALUE|s at the end. 308 boolean unexpected = false; 309 int actualExtraRecords = 0; 310 311 for (HistogramBucket actualBucket : actualBuckets) { 312 if (expectedValueAndCount == null) { 313 // No expected values are left, so all records seen in this bucket are extra. 314 actualExtraRecords += actualBucket.mCount; 315 continue; 316 } 317 318 // Count how many expected records fall inside the bucket. 319 int expectedRecordsMatchedToActualBucket = 0; 320 do { 321 int expectedValue = expectedValueAndCount.getKey(); 322 int expectedCount = expectedValueAndCount.getValue(); 323 if (actualBucket.contains(expectedValue)) { 324 expectedRecordsMatchedToActualBucket += expectedCount; 325 expectedValueAndCount = 326 expectedValuesAndCountsIt.hasNext() 327 ? expectedValuesAndCountsIt.next() 328 : null; 329 } else { 330 break; 331 } 332 } while (expectedValueAndCount != null); 333 334 if (actualBucket.mCount > expectedRecordsMatchedToActualBucket) { 335 // Saw more records than expected for that bucket's range. 336 // Consider the difference as extra records. 337 actualExtraRecords += actualBucket.mCount - expectedRecordsMatchedToActualBucket; 338 } else if (actualBucket.mCount < expectedRecordsMatchedToActualBucket) { 339 // Saw fewer records than expected for that bucket's range. 340 // Assert since all expected records should be accounted for. 341 unexpected = true; 342 break; 343 } 344 // else, actual records match expected, so just move to check the next actual bucket. 345 } 346 347 if (expectedValueAndCount != null) { 348 // Still had more expected values but not seen in any actual bucket. 349 unexpected = true; 350 } 351 352 boolean allowAnyNumberOfExtraRecords = mHistogramsAllowedExtraRecords.contains(histogram); 353 Integer expectedExtraRecords = 354 mRecordsExpected.get(new HistogramAndValue(histogram, ANY_VALUE)); 355 if (expectedExtraRecords == null) { 356 expectedExtraRecords = 0; 357 } 358 if (!allowAnyNumberOfExtraRecords && actualExtraRecords > expectedExtraRecords 359 || actualExtraRecords < expectedExtraRecords) { 360 // Expected |extraRecordsExpected| records with any value, found |extraActualRecords|. 361 unexpected = true; 362 } 363 364 if (unexpected) { 365 String expectedRecordsString = 366 getExpectedHistogramSamplesAsString(expectedValuesAndCounts); 367 String actualRecordsString = bucketsToString(actualBuckets); 368 String atLeastString = allowAnyNumberOfExtraRecords ? "At least " : ""; 369 int expectedTotalDelta = 0; 370 for (Integer expectedCount : expectedValuesAndCounts.values()) { 371 expectedTotalDelta += expectedCount; 372 } 373 int actualTotalDelta = 0; 374 for (HistogramBucket actualBucket : actualBuckets) { 375 actualTotalDelta += actualBucket.mCount; 376 } 377 String defaultMessage = 378 String.format( 379 "Records for histogram \"%s\" did not match expected.\n" 380 + "%s%d record(s) expected: [%s]\n" 381 + "%d record(s) seen: [%s]", 382 histogram, 383 atLeastString, 384 expectedTotalDelta, 385 expectedRecordsString, 386 actualTotalDelta, 387 actualRecordsString); 388 failWithDefaultOrCustomMessage(defaultMessage, customMessage); 389 } 390 } 391 getExpectedHistogramSamplesAsString( TreeMap<Integer, Integer> expectedValuesAndCounts)392 private static String getExpectedHistogramSamplesAsString( 393 TreeMap<Integer, Integer> expectedValuesAndCounts) { 394 List<String> expectedRecordsStrings = new ArrayList<>(); 395 for (Entry<Integer, Integer> kv : expectedValuesAndCounts.entrySet()) { 396 int value = kv.getKey(); 397 int count = kv.getValue(); 398 if (value == ANY_VALUE) { 399 // Write records matching "Any" at the end. 400 continue; 401 } 402 expectedRecordsStrings.add(bucketToString(value, value + 1, count)); 403 } 404 405 if (expectedValuesAndCounts.containsKey(ANY_VALUE)) { 406 int anyExpectedCount = expectedValuesAndCounts.get(ANY_VALUE); 407 expectedRecordsStrings.add(bucketToString(ANY_VALUE, ANY_VALUE + 1, anyExpectedCount)); 408 } 409 410 return String.join(", ", expectedRecordsStrings); 411 } 412 computeActualBuckets(String histogram)413 private List<HistogramBucket> computeActualBuckets(String histogram) { 414 List<HistogramBucket> startingBuckets = mStartingSamples.get(histogram); 415 List<HistogramBucket> finalBuckets = 416 RecordHistogram.getHistogramSamplesForTesting(histogram); 417 List<HistogramBucket> deltaBuckets = new ArrayList<>(); 418 419 PeekingIterator<HistogramBucket> startingBucketsIt = 420 Iterators.peekingIterator(startingBuckets.iterator()); 421 422 for (HistogramBucket finalBucket : finalBuckets) { 423 int totalInEquivalentStartingBuckets = 0; 424 while (startingBucketsIt.hasNext() 425 && startingBucketsIt.peek().mMax <= finalBucket.mMax) { 426 HistogramBucket startBucket = startingBucketsIt.next(); 427 if (startBucket.mMin >= finalBucket.mMax) { 428 // This should not happen as the only transition in bucket schema is from the 429 // CachingUmaRecord (which is as granular as possible, buckets of [n, n+1) ) 430 // to NativeUmaRecorder (which has varying granularity). 431 fail( 432 String.format( 433 "Histogram bucket bounds before and after the test don't match," 434 + " cannot assert histogram counts.\n" 435 + "Before: [%s]\n" 436 + "After: [%s]", 437 bucketsToString(startingBuckets), 438 bucketsToString(finalBuckets))); 439 } 440 if (startBucket.mMin >= finalBucket.mMin) { 441 // Since start.max <= final.max, this means the start bucket is contained in the 442 // final bucket. 443 totalInEquivalentStartingBuckets += startBucket.mCount; 444 } 445 } 446 447 int delta = finalBucket.mCount - totalInEquivalentStartingBuckets; 448 449 if (delta == 0) { 450 // Empty buckets don't need to be printed. 451 continue; 452 } else { 453 deltaBuckets.add(new HistogramBucket(finalBucket.mMin, finalBucket.mMax, delta)); 454 } 455 } 456 return deltaBuckets; 457 } 458 bucketsToString(List<HistogramBucket> buckets)459 private static String bucketsToString(List<HistogramBucket> buckets) { 460 List<String> bucketStrings = new ArrayList<>(); 461 for (HistogramBucket bucket : buckets) { 462 bucketStrings.add(bucketToString(bucket)); 463 } 464 return String.join(", ", bucketStrings); 465 } 466 bucketToString(HistogramBucket bucket)467 private static String bucketToString(HistogramBucket bucket) { 468 return bucketToString(bucket.mMin, bucket.mMax, bucket.mCount); 469 } 470 bucketToString(int bucketMin, long bucketMax, int count)471 private static String bucketToString(int bucketMin, long bucketMax, int count) { 472 String bucketString; 473 if (bucketMin == ANY_VALUE) { 474 bucketString = "Any"; 475 } else if (bucketMax == bucketMin + 1) { 476 // bucketString is "100" for bucketMin == 100, bucketMax == 101 477 bucketString = String.valueOf(bucketMin); 478 } else { 479 // bucketString is "[100, 120)" for bucketMin == 100, bucketMax == 120 480 bucketString = String.format("[%d, %d)", bucketMin, bucketMax); 481 } 482 483 if (count == 1) { 484 // result is "100" for count == 1 485 return bucketString; 486 } else { 487 // result is "100 (2 times)" for count == 2 488 return String.format("%s (%d times)", bucketString, count); 489 } 490 } 491 failWithDefaultOrCustomMessage( String defaultMessage, @Nullable String customMessage)492 private static void failWithDefaultOrCustomMessage( 493 String defaultMessage, @Nullable String customMessage) { 494 if (customMessage != null) { 495 fail(String.format("%s\n%s", customMessage, defaultMessage)); 496 } else { 497 fail(defaultMessage); 498 } 499 } 500 501 /** 502 * Polls the instrumentation thread until the expected histograms are recorded. 503 * 504 * Throws {@link CriteriaNotSatisfiedException} if the polling times out, wrapping the 505 * assertion to printed out the state of the histograms at the last check. 506 */ pollInstrumentationThreadUntilSatisfied()507 public void pollInstrumentationThreadUntilSatisfied() { 508 CriteriaHelper.pollInstrumentationThread( 509 () -> { 510 try { 511 assertExpected(); 512 return true; 513 } catch (AssertionError e) { 514 throw new CriteriaNotSatisfiedException(e); 515 } 516 }); 517 } 518 519 private static class HistogramAndValue { 520 private final String mHistogram; 521 private final int mValue; 522 HistogramAndValue(String histogram, int value)523 private HistogramAndValue(String histogram, int value) { 524 mHistogram = histogram; 525 mValue = value; 526 } 527 528 @Override hashCode()529 public int hashCode() { 530 return Objects.hash(mHistogram, mValue); 531 } 532 533 @Override equals(@ullable Object obj)534 public boolean equals(@Nullable Object obj) { 535 if (obj instanceof HistogramAndValue) { 536 HistogramAndValue that = (HistogramAndValue) obj; 537 return this.mHistogram.equals(that.mHistogram) && this.mValue == that.mValue; 538 } 539 return false; 540 } 541 } 542 } 543