1 /* 2 * Copyright (C) 2023 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 package com.android.adservices.experimental; 17 18 import static java.lang.annotation.ElementType.METHOD; 19 import static java.lang.annotation.ElementType.TYPE; 20 import static java.lang.annotation.RetentionPolicy.RUNTIME; 21 22 import com.android.adservices.shared.testing.Logger; 23 import com.android.adservices.shared.testing.Nullable; 24 25 import com.google.common.collect.ImmutableList; 26 27 import org.junit.AssumptionViolatedException; 28 import org.junit.runner.Description; 29 import org.junit.runner.notification.RunNotifier; 30 import org.junit.runners.BlockJUnit4ClassRunner; 31 import org.junit.runners.model.FrameworkMethod; 32 import org.junit.runners.model.InitializationError; 33 import org.junit.runners.model.Statement; 34 import org.junit.runners.model.TestClass; 35 36 import java.lang.annotation.Repeatable; 37 import java.lang.annotation.Retention; 38 import java.lang.annotation.Target; 39 import java.util.ArrayList; 40 import java.util.Arrays; 41 import java.util.Collection; 42 import java.util.Collections; 43 import java.util.List; 44 import java.util.Objects; 45 import java.util.stream.Collectors; 46 47 // TODO(b/284971005): move to module-utils 48 // TODO(b/284971005): rename / make it clear it's for boolean flags (and/or make it genric?) 49 // TODO(b/284971005): improve javadoc / add examples 50 // TODO(b/284971005): add unit tests 51 /** 52 * Test runner used to run tests with all combinations of a set of boolean flags (the {@link 53 * #getFlagsRoulette(TestClass, List) "roulette"}). 54 * 55 * <p>For example, if a test depends on flags {@code [foo, bar]}, the runner will run the tests with 56 * 4 combinations: 57 * 58 * <ol> 59 * <li>foo=false, bar=false 60 * <li>foo=false, bar=true 61 * <li>foo=true, bar=false 62 * <li>foo=true, bar=true 63 * </ol> 64 * 65 * <p>The runner also support test-level optimizations; for example, if a test only needs to run 66 * when the {@code foo} flag is on, it can be annotated with the {@link RequiresFlagOn} annotation 67 * (and value {@code "foo"}). 68 */ 69 public abstract class AbstractFlagsRouletteRunner extends BlockJUnit4ClassRunner { 70 71 // TODO(b/284971005): currently class only support boolean flags - extend it to be more generic? 72 private static final boolean[] FLAG_VALUES = {false, true}; 73 74 // NOTE: super() calls collectInitializationErrors(), so that method (or methods called by it) 75 // will throw a NPE if they access a field that's not initialized yet (that's why this class 76 // doesn't provide a mLog but requires a log(), for example) 77 private final FlagsManager mFlagsManager; 78 private List<String> mFlagsRoulette; // see getFlagsRoulette() 79 private List<FrameworkMethod> mModifiedMethods; 80 81 private static final ThreadLocal<FlagsRouletteState> sCurrentRunner = new ThreadLocal<>(); 82 AbstractFlagsRouletteRunner(Class<?> testClass, FlagsManager manager)83 protected AbstractFlagsRouletteRunner(Class<?> testClass, FlagsManager manager) 84 throws InitializationError { 85 super(testClass); 86 87 mFlagsManager = Objects.requireNonNull(manager); 88 log().i( 89 "AbstractFlagsRouletteRunner(): mFlagsManager=%s, mFlagsRoulette=%s", 90 mFlagsManager, mFlagsRoulette); 91 } 92 93 /** Provides the {@link Logger}. */ log()94 protected abstract Logger log(); 95 96 /** 97 * Defines which flags the runner will set before running the tests. 98 * 99 * <p>By default, it will try to infer it from the following annotations: 100 * 101 * <ol> 102 * <li>{@link FlagsProviderClass} 103 * <li>{@link FlagsRoulette} 104 * </ol> 105 * 106 * <p>But sub-classes can override it to simplify the tests (for, example to return a 107 * pre-defined combination of flags). 108 * 109 * @param testClass test being run 110 * @param errors used to report errors back to JUNit 111 * @return which flags the runner will set before running the tests 112 */ getFlagsRoulette(TestClass testClass, List<Throwable> errors)113 protected String[] getFlagsRoulette(TestClass testClass, List<Throwable> errors) { 114 // First try provider... 115 FlagsProviderClass flagsProviderClass = testClass.getAnnotation(FlagsProviderClass.class); 116 if (flagsProviderClass != null) { 117 return getFlagsFromProvider( 118 flagsProviderClass.value(), testClass.getJavaClass(), errors); 119 } 120 // ...then annotation 121 FlagsRoulette annotation = testClass.getAnnotation(FlagsRoulette.class); 122 if (annotation != null) { 123 String[] flags = annotation.value(); 124 log().i( 125 "getRouletteFlags(): returning flags from @%s: %s", 126 FlagsRoulette.class, Arrays.toString(flags)); 127 return flags; 128 } 129 // ...or fail! 130 errors.add( 131 new IllegalStateException( 132 "Could not infer roulette flags: " 133 + getClass() 134 + " doesn't override getRouletteFlags() and " 135 + testClass 136 + "is not annotated with @" 137 + FlagsRoulette.class 138 + " or @" 139 + FlagsProvider.class)); 140 return null; 141 } 142 143 /** 144 * Override this method and return {@code false} to disable the runner (so it will run every 145 * tests "as-is"). 146 */ isDisabled()147 protected boolean isDisabled() { 148 return false; 149 } 150 151 /** 152 * Gets the flag states that are required by a test method, so the method is skipped when the 153 * test is run with flags that don't match it. 154 * 155 * <p>By default it returns the values provided by the {@link RequiresFlag}, but subclasses can 156 * override it to use other annotations. 157 * 158 * @return flag states that are required by a test method or {@code null} if test doesn't have 159 * any flag restriction. 160 */ getRequiredFlagStates(FrameworkMethod method)161 protected @Nullable FlagState[] getRequiredFlagStates(FrameworkMethod method) { 162 // Try individual annotation first 163 RequiresFlag singleAnnotation = method.getAnnotation(RequiresFlag.class); 164 if (singleAnnotation != null) { 165 return new FlagState[] {new FlagState(singleAnnotation)}; 166 } 167 RequiresFlags groupAnnotation = method.getAnnotation(RequiresFlags.class); 168 return groupAnnotation == null 169 ? null 170 : Arrays.stream(groupAnnotation.value()) 171 .map((annotation -> new FlagState(annotation))) 172 .toArray(FlagState[]::new); 173 } 174 175 // TODO(b/284971005): this method (which is called by the constructor) is not only collecting 176 // errors but also initializing mFlagsRoulette, ideally we should split (and move the roulette 177 // initialization to some later stages) 178 @Override collectInitializationErrors(List<Throwable> errors)179 protected final void collectInitializationErrors(List<Throwable> errors) { 180 if (isDisabled()) { 181 log().v("collectInitializationErrors(): ignoring because isDisabled() returned true"); 182 super.collectInitializationErrors(errors); 183 return; 184 } 185 TestClass testClass = getTestClass(); 186 String[] flagsRoulette = getFlagsRoulette(testClass, errors); 187 log().v( 188 "collectInitializationErrors(): flags=%s, errors=%s", 189 Arrays.toString(flagsRoulette), errors); 190 if (flagsRoulette == null || flagsRoulette.length == 0) { 191 // Test run will eventually fail due to errors, but it's better to set mFlags anyways, 192 // just in case (to avoid NPE) 193 mFlagsRoulette = Collections.emptyList(); 194 if (errors.isEmpty()) { 195 errors.add( 196 new IllegalStateException( 197 getClass() + " overrode getRouletteFlags() but returned null")); 198 } 199 } else { 200 mFlagsRoulette = 201 new ArrayList<>(Arrays.stream(flagsRoulette).collect(Collectors.toSet())); 202 } 203 204 super.collectInitializationErrors(errors); 205 } 206 207 /** 208 * {@inheritDoc} 209 * 210 * <p>This method will create multiple {@link FlagSnapshotMethod FlagSnapshotMethods} for each 211 * "real" method, one with each of the required flag snapshots. For example, if the roulette 212 * contains flags {@code FlagA} and {@code FlagB}, it would by default return 4 {@link 213 * FlagSnapshotMethod FlagSnapshotMethods} for each method (for the 4 combinations of {@code 214 * true} and {@code false}, but it has some additional logic: 215 * 216 * <ul> 217 * <li>Returns the original methods if the runner is {@link #isDisabled() disabled}. 218 * <li>Ignores snapshots whose flags values would invalidate the flag requirements the method 219 * is annotated with (for example, if method requires that {@code FlagA=true}, then it 220 * would just return 2 snapshots for that method - {@code FlagA=true, FlagB=false} and 221 * {@code FlagA=true, FlagB=true}). 222 * </ul> 223 */ 224 @Override computeTestMethods()225 protected final List<FrameworkMethod> computeTestMethods() { 226 if (isDisabled()) { 227 log().v("computeTestMethods(): ignoring because isDisabled() returned true"); 228 return super.computeTestMethods(); 229 } 230 log().d( 231 "computeTestMethods(): mFlags=%s, mModifiedMethods=%s", 232 mFlagsRoulette, 233 mModifiedMethods == null ? "null" : "" + mModifiedMethods.size()); 234 235 if (mModifiedMethods != null) { 236 // TODO(b/284971005): this method is called twice (by validate(), which is called by the 237 // constructor, and by getChildren(), which is called by runChildren()), so we're 238 // caching the results because it seems to be unnecessary to re-calculate them. But we 239 // need to investigate it further to make sure (for example, it's called by 240 // validateInstanceMethods(), which is @Deprecated) 241 return mModifiedMethods; 242 } 243 List<FrameworkMethod> originalMethods = super.computeTestMethods(); 244 mModifiedMethods = new ArrayList<>(); 245 List<List<FlagState>> flagsSnapshots = getFlagsSnapshots(); 246 log().v( 247 "computeTestMethods(): %d flags snapshots: %s", 248 flagsSnapshots.size(), flagsSnapshots); 249 250 for (FrameworkMethod method : originalMethods) { 251 FlagState[] requiredFlagStates = getRequiredFlagStates(method); 252 for (List<FlagState> snapshot : flagsSnapshots) { 253 FrameworkMethod modifiedMethod = null; 254 if (requiredFlagStates != null) { 255 modifiedMethod = 256 FlagSnapshotMethod.forRequiredFlagStates( 257 method, requiredFlagStates, snapshot, log()); 258 } 259 if (modifiedMethod == null) { 260 modifiedMethod = 261 new FlagSnapshotMethod(method, snapshot, /* skippedException= */ null); 262 } 263 mModifiedMethods.add(modifiedMethod); 264 } 265 } 266 267 sortMethodsByFlagSnapshotsAndName(mModifiedMethods); 268 269 log().d( 270 "computeTestMethods(): %d originalMethods, %d modifiedMethods", 271 originalMethods.size(), mModifiedMethods.size()); 272 return mModifiedMethods; 273 } 274 275 @Override runChild(FrameworkMethod method, RunNotifier notifier)276 protected void runChild(FrameworkMethod method, RunNotifier notifier) { 277 if (isDisabled()) { 278 log().v("runChild(): ignoring because isDisabled() returned true"); 279 super.runChild(method, notifier); 280 return; 281 } 282 283 FlagSnapshotMethod fsMethod = castFlagSnapshotMethod(method); 284 if (fsMethod.mSkippedException != null) { 285 Description description = describeChild(method); 286 Statement statement = 287 new Statement() { 288 @Override 289 public void evaluate() throws Throwable { 290 throw fsMethod.mSkippedException; 291 } 292 }; 293 runLeaf(statement, description, notifier); 294 return; 295 } 296 297 mFlagsManager.setFlags(fsMethod.getFlagsSnapshot()); 298 sCurrentRunner.set(new FlagsRouletteState(this)); 299 try { 300 super.runChild(method, notifier); 301 } finally { 302 sCurrentRunner.remove(); 303 resetFlags(); 304 } 305 } 306 307 @Nullable getFlagsFromProvider( Class<? extends FlagsProvider> providerClass, Class<?> testClass, List<Throwable> errors)308 private String[] getFlagsFromProvider( 309 Class<? extends FlagsProvider> providerClass, 310 Class<?> testClass, 311 List<Throwable> errors) { 312 FlagsProvider provider = null; 313 try { 314 provider = providerClass.getDeclaredConstructor().newInstance(); 315 } catch (Exception e) { 316 errors.add(e); 317 return null; 318 } 319 String[] flags = provider.getFlags(testClass); 320 if (flags == null || flags.length == 0) { 321 errors.add( 322 new IllegalStateException("Provider " + provider + " didn't return any flag")); 323 return null; 324 } 325 log().d("Flags from %s for %s: %s", provider, testClass, Arrays.toString(flags)); 326 return flags; 327 } 328 sortMethodsByFlagSnapshotsAndName(List<FrameworkMethod> genericMethods)329 private void sortMethodsByFlagSnapshotsAndName(List<FrameworkMethod> genericMethods) { 330 genericMethods.sort( 331 (o1, o2) -> { 332 FlagSnapshotMethod method1 = castFlagSnapshotMethod(o1); 333 FlagSnapshotMethod method2 = castFlagSnapshotMethod(o2); 334 List<FlagState> snapshots1 = method1.getFlagsSnapshot(); 335 List<FlagState> snapshots2 = method2.getFlagsSnapshot(); 336 int size1 = snapshots1.size(); 337 int size2 = snapshots2.size(); 338 if (size1 != size2) { 339 // Shouldn't happen 340 log().e( 341 "sortMethodsByFlagSnapshots(): sizes mismatch (%d != %d)," 342 + " method1=%s, method2=%s", 343 size1, size2, o1, o2); 344 return size1 - size2; 345 } 346 for (int i = 0; i < size1; i++) { 347 boolean value1 = snapshots1.get(i).value; 348 boolean value2 = snapshots2.get(i).value; 349 if (value1 != value2) { 350 return sortMethodsByFlags(value1, value2); 351 } 352 } 353 // Tests have same flag snapshots - sort by name 354 return sortMethodsByName( 355 method1.getMethod().getName(), method2.getMethod().getName()); 356 }); 357 } 358 359 /** Defines the order of the test execution based on flags values. */ sortMethodsByFlags(boolean flag1, boolean flag2)360 protected int sortMethodsByFlags(boolean flag1, boolean flag2) { 361 return flag1 ? 1 : -1; 362 } 363 364 /** Defines the order of the test execution based on test name. */ sortMethodsByName(String name1, String name2)365 protected int sortMethodsByName(String name1, String name2) { 366 return name1.compareTo(name2); 367 } 368 resetFlags()369 private void resetFlags() { 370 mFlagsManager.resetFlags(); 371 } 372 373 // TODO(b/284971005): this is not the most optional way to combine the 2^N values, but it's fine 374 // for now getFlagsSnapshots()375 private ImmutableList<List<FlagState>> getFlagsSnapshots() { 376 if (mFlagsRoulette.isEmpty()) { 377 return ImmutableList.of(); 378 } 379 List<List<FlagState>> snapshots = new ArrayList<>(); 380 ArrayList<FlagState> flagsSnapshots = new ArrayList<>(); 381 getFlagSnapshots(snapshots, flagsSnapshots, 0); 382 return ImmutableList.copyOf(snapshots); 383 } 384 getFlagSnapshots( List<List<FlagState>> snapshots, List<FlagState> previousSnapshots, int index)385 private void getFlagSnapshots( 386 List<List<FlagState>> snapshots, List<FlagState> previousSnapshots, int index) { 387 String flag = mFlagsRoulette.get(index); 388 for (boolean value : FLAG_VALUES) { 389 List<FlagState> newSnapshots = new ArrayList<>(previousSnapshots); 390 newSnapshots.add(new FlagState(flag, value)); 391 if (index < mFlagsRoulette.size()) { 392 for (int i = index + 1; i < mFlagsRoulette.size(); i++) { 393 getFlagSnapshots(snapshots, newSnapshots, i); 394 } 395 } 396 if (newSnapshots.size() == mFlagsRoulette.size()) { 397 log().v("Adding new snapshots: %s", newSnapshots); 398 snapshots.add(newSnapshots); 399 } 400 } 401 } 402 castFlagSnapshotMethod(FrameworkMethod method)403 private static FlagSnapshotMethod castFlagSnapshotMethod(FrameworkMethod method) { 404 if (!(method instanceof FlagSnapshotMethod)) { 405 throw new IllegalStateException("Invalid method : " + method); 406 } 407 return (FlagSnapshotMethod) method; 408 } 409 toString(List<FlagState> snapshots)410 private static String toString(List<FlagState> snapshots) { 411 StringBuilder string = new StringBuilder().append('['); 412 int size = snapshots.size(); 413 for (int i = 0; i < size; i++) { 414 FlagState snapshot = snapshots.get(i); 415 string.append(snapshot.name).append('=').append(snapshot.value); 416 if (i < size - 1) { 417 string.append(','); 418 } 419 } 420 return string.append(']').toString(); 421 } 422 423 // TODO(b/284971005): move to its own class? 424 @SuppressWarnings("serial") 425 public static final class FlagStateAssumptionViolatedException 426 extends AssumptionViolatedException { 427 FlagStateAssumptionViolatedException(FlagState flagState, FrameworkMethod method)428 private FlagStateAssumptionViolatedException(FlagState flagState, FrameworkMethod method) { 429 super( 430 "Ignoring " 431 + method.getName() 432 + " when turning flag " 433 + flagState.name 434 + (flagState.value ? " ON" : " OFF")); 435 } 436 } 437 438 // TODO(b/284971005): move to its own class? 439 private static final class FlagSnapshotMethod extends FrameworkMethod { 440 private final List<FlagState> mFlagsSnapshot; 441 private final @Nullable AssumptionViolatedException mSkippedException; 442 FlagSnapshotMethod( FrameworkMethod method, List<FlagState> flagsSnapshot, AssumptionViolatedException skippedException)443 private FlagSnapshotMethod( 444 FrameworkMethod method, 445 List<FlagState> flagsSnapshot, 446 AssumptionViolatedException skippedException) { 447 super(method.getMethod()); 448 mFlagsSnapshot = flagsSnapshot; 449 mSkippedException = skippedException; 450 } 451 452 @Nullable forRequiredFlagStates( FrameworkMethod method, FlagState[] requiredFlagStates, List<FlagState> flagsSnapshot, Logger log)453 private static FlagSnapshotMethod forRequiredFlagStates( 454 FrameworkMethod method, 455 FlagState[] requiredFlagStates, 456 List<FlagState> flagsSnapshot, 457 Logger log) { 458 if (requiredFlagStates == null) { 459 return null; 460 } 461 log.v( 462 "forRequiredFlagStates(): method=%s, requiredFlagStates=%s, flagSnapshots=%s", 463 method.getName(), requiredFlagStates, flagsSnapshot); 464 // TODO(b/284971005): optimize it (instead of O(N x M) 465 for (FlagState flagSnapshot : flagsSnapshot) { 466 for (FlagState requiredFlagState : requiredFlagStates) { 467 if (flagSnapshot.name.equals(requiredFlagState.name)) { 468 log.v( 469 "forRequiredFlags(): found required flag state (%s) on snapshot %s", 470 requiredFlagState, flagSnapshot); 471 boolean requiresOn = requiredFlagState.value; 472 boolean isOn = flagSnapshot.value; 473 if (requiresOn != isOn) { 474 return new FlagSnapshotMethod( 475 method, 476 flagsSnapshot, 477 new FlagStateAssumptionViolatedException(flagSnapshot, method)); 478 } 479 } 480 } 481 } 482 log.v( 483 "forRequiredFlagStates(): method %s is annotated with required flag states" 484 + " (%s), but they were not found in the flags snapshot (%s)", 485 method.getName(), requiredFlagStates, flagsSnapshot); 486 return null; 487 } 488 getFlagsSnapshot()489 private List<FlagState> getFlagsSnapshot() { 490 return mFlagsSnapshot; 491 } 492 493 @Override getName()494 public String getName() { 495 return super.getName() + AbstractFlagsRouletteRunner.toString(mFlagsSnapshot); 496 } 497 498 @Override hashCode()499 public int hashCode() { 500 final int prime = 31; 501 int result = super.hashCode(); 502 result = prime * result + Objects.hash(mFlagsSnapshot); 503 return result; 504 } 505 506 @Override equals(Object obj)507 public boolean equals(Object obj) { 508 if (this == obj) return true; 509 if (!super.equals(obj)) return false; 510 if (getClass() != obj.getClass()) return false; 511 FlagSnapshotMethod other = (FlagSnapshotMethod) obj; 512 return Objects.equals(mFlagsSnapshot, other.mFlagsSnapshot); 513 } 514 515 @Override toString()516 public String toString() { 517 return "[" 518 + getClass().getSimpleName() 519 + ": method=" 520 + getMethod() 521 + ", flags=" 522 + AbstractFlagsRouletteRunner.toString(mFlagsSnapshot) 523 + ']'; 524 } 525 } 526 527 /** 528 * Annotation used to indicate that a test method should once be run when the given {@link 529 * RequiresFlag#name() name} is set with the given {@link RequiresFlag#value() value}. 530 */ 531 @Retention(RUNTIME) 532 @Target({TYPE, METHOD}) 533 @Repeatable(RequiresFlags.class) 534 public static @interface RequiresFlag { 535 /** Name of the flag. */ name()536 String name(); 537 /** Value the flag should have when the test is running */ value()538 boolean value() default true; 539 } 540 541 @Retention(RUNTIME) 542 @Target({TYPE, METHOD}) 543 public static @interface RequiresFlags { value()544 RequiresFlag[] value(); 545 } 546 547 // TODO(b/284971005): move to its own class? 548 /** Provides the flags used by the test. */ 549 public interface FlagsProvider { getFlags(Class<?> testClass)550 String[] getFlags(Class<?> testClass); 551 } 552 553 /** 554 * Annotation used to define which flags will be set when the tests are run - see {@link 555 * AbstractFlagsRouletteRunner#getFlagsRoulette(TestClass, List)}. 556 */ 557 @Retention(RUNTIME) 558 @Target(TYPE) 559 public static @interface FlagsRoulette { value()560 String[] value(); 561 } 562 563 /** 564 * Annotation used to define which flags will be set when the tests are run - see {@link 565 * AbstractFlagsRouletteRunner#getFlagsRoulette(TestClass, List)}. 566 */ 567 @Retention(RUNTIME) 568 @Target(TYPE) 569 public static @interface FlagsProviderClass { value()570 Class<? extends FlagsProvider> value(); 571 } 572 573 /** Abstraction used to manage the values of flags when the tests are run. */ 574 public interface FlagsManager { 575 576 /** Sets the values of the given flags. */ setFlags(Collection<FlagState> flags)577 void setFlags(Collection<FlagState> flags); 578 579 /** Resets the value of all flags that were set by {@link #setFlags(Collection)}. */ resetFlags()580 void resetFlags(); 581 } 582 583 // TODO(b/284971005): move to its own class? 584 /** POJO (Plain-Old Java Object) containing the value of a flag. */ 585 public static final class FlagState { 586 public final String name; 587 public final boolean value; 588 FlagState(String name, boolean value)589 public FlagState(String name, boolean value) { 590 this.name = name; 591 this.value = value; 592 } 593 FlagState(RequiresFlag annotation)594 private FlagState(RequiresFlag annotation) { 595 this(annotation.name(), annotation.value()); 596 } 597 598 @Override hashCode()599 public int hashCode() { 600 return Objects.hash(name, value); 601 } 602 603 @Override equals(Object obj)604 public boolean equals(Object obj) { 605 if (this == obj) return true; 606 if (obj == null) return false; 607 if (getClass() != obj.getClass()) return false; 608 FlagState other = (FlagState) obj; 609 return Objects.equals(name, other.name) && value == other.value; 610 } 611 612 @Override toString()613 public String toString() { 614 return "[" + name + "=" + value + "]"; 615 } 616 } 617 618 /** 619 * Gets the info about the runner running the current test (or {@code null} if no test is 620 * running). 621 */ getFlagsRouletteState()622 public static @Nullable FlagsRouletteState getFlagsRouletteState() { 623 return sCurrentRunner.get(); 624 } 625 626 /** 627 * Info about the runner running the current test. 628 * 629 * <p>Obtained by {@link #getFlagsRouletteState()} 630 */ 631 public static final class FlagsRouletteState { 632 public final String runnerName; 633 public final List<String> flagNames; 634 FlagsRouletteState(AbstractFlagsRouletteRunner runner)635 private FlagsRouletteState(AbstractFlagsRouletteRunner runner) { 636 runnerName = runner.getClass().getSimpleName(); 637 flagNames = Collections.unmodifiableList(new ArrayList<>(runner.mFlagsRoulette)); 638 } 639 } 640 } 641