Repository: Prospect-Robotics/lib2813
Pull request #27: Introduce PreferencesInjector
Author: login: kcooney
Created: 2025-03-06T03:39:48Z | Merged: 2025-03-08T23:07:27Z

This allows preference values to be easily injected into record classes.


Diff:
diff --git a/lib/build.gradle b/lib/build.gradle
index f8ec3322..d1b0b4a7 100644
--- a/lib/build.gradle
+++ b/lib/build.gradle
@@ -49,6 +49,7 @@ dependencies {
     simulationRelease wpi.sim.enableRelease()
 
     testImplementation 'junit:junit:4.13.2'
+    testImplementation 'com.google.truth:truth:1.4.4'
 }
 
 // the magic line that makes tests work :)
diff --git a/lib/src/main/java/com/team2813/lib2813/preferences/PreferencesInjector.java b/lib/src/main/java/com/team2813/lib2813/preferences/PreferencesInjector.java
new file mode 100644
index 00000000..bb624dd1
--- /dev/null
+++ b/lib/src/main/java/com/team2813/lib2813/preferences/PreferencesInjector.java
@@ -0,0 +1,354 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+/**
+ * Initializes the fields of a Record Class from values stored in {@link Preferences}.
+ *
+ * <p>Example use:
+ *
+ * <pre>
+ * public final class Drive {
+ *
+ *   public record DriveConfiguration(
+ *       boolean addLimelightMeasurement, long robotWeight,
+ *       DoubleSupplier powerMultiplier) {
+ *
+ *     public static DriveConfiguration fromPreferences() {
+ *       DriveConfiguration defaultConfig = new DriveConfiguration(
+ *           true, 137, () -> 3.14);
+ *       return PreferencesInjector.DEFAULT_INSTANCE.injectPreferences(defaultConfig);
+ *     }
+ *   }
+ * }
+ * </pre>
+ *
+ * <p>In the above example, values will be stored in NetworkTables under the "Preferences" table for
+ * each of the components of the {@code DriveConfiguration} record. The keys would be:
+ *
+ * <ul>
+ *   <li>"Drive.DriveConfiguration.addLimelightMeasurement"
+ *   <li>"Drive.DriveConfiguration.robotWeight"
+ *   <li>"Drive.DriveConfiguration.powerMultiplier"
+ * </ul>
+ *
+ * <p>The default values of for these Preference values will be the values provided to {@link
+ * PreferencesInjector#injectPreferences(Record)}. The values can be updated in the SmartDashboard
+ * and/or Shuffleboard UI; updated values will be stored in the flash storage for the robot.
+ */
+public class PreferencesInjector {
+  /**
+   * Injector instance that removes "com.team2813." from class names when creating prefence key
+   * names.
+   */
+  public static final PreferencesInjector DEFAULT_INSTANCE =
+      new PreferencesInjector("com.team2813.");
+
+  private final String removePrefix;
+  private final int removePrefixLen;
+
+  // The below package-scope fields are for the self-tests.
+  boolean throwExceptions = false;
+  Consumer<String> errorReporter = DataLogManager::log;
+
+  /**
+   * Creates an instance of the injector.
+   *
+   * <p>Takes in a prefix string, which is used when converting class names to Preference keys. When
+   * a record class is passed to the injector, the code generates the Preference keys by
+   * concatenating the canonical name of the class with the name of the record component (aka
+   * "record field"). This can result in long keys; to avoid this, callers can pass in a prefix into
+   * this constructor, and if the generated keys start with that prefix, the prefix will be removed
+   * when generating the key.
+   *
+   * @param removePrefix String to remove (if present) from the start of generated keys.
+   */
+  public PreferencesInjector(String removePrefix) {
+    this.removePrefix = removePrefix;
+    this.removePrefixLen = removePrefix.length();
+  }
+
+  /**
+   * Creates an instance of the given record class with all fields populated from Preferences.
+   *
+   * <p>To be stored in preferences, the type of the record components can be any of the following:
+   *
+   * <ul>
+   *   <li>{@code boolean} or {@code BooleanSupplier} or {@code Supplier<Boolean>}
+   *   <li>{@code int} or {@code IntSupplier} or {@code Supplier<Integer>}
+   *   <li>{@code long} or {@code LongSupplier} or {@code Supplier<Long>}
+   *   <li>{@code double} or {@code DoubleSupplier} or {@code Supplier<Double>}
+   *   <li>{@code String} or {@code Supplier<String>}
+   * </ul>
+   *
+   * <p>The values for the components for the passed-in instance will be used as the default value
+   * for the preference. If a component is a supplier, the supplier will be called at most once to
+   * get the default instance. Suppliers cannot return {@code null}.
+   *
+   * @param configWithDefaults Record instance with all values set to their preferred default
+   *     values.
+   */
+  public final <T extends java.lang.Record> T injectPreferences(T configWithDefaults) {
+    @SuppressWarnings("unchecked")
+    Class<? extends T> clazz = (Class<? extends T>) configWithDefaults.getClass();
+
+    try {
+      return injectPreferences(clazz, configWithDefaults);
+    } catch (ReflectiveOperationException e) {
+      if (throwExceptions) {
+        throw new RuntimeException(e); // For self-tests.
+      }
+      DriverStation.reportWarning(
+          String.format("Could not inject preferences into %s: %s", clazz.getSimpleName(), e),
+          e.getStackTrace());
+      return configWithDefaults;
+    }
+  }
+
+  /**
+   * Creates a preference key for the given record component.
+   *
+   * <p>This method is protected and non-final so subclasses can change the default behavior.
+   */
+  protected String createKey(RecordComponent component) {
+    Class<?> recordClass = component.getDeclaringRecord();
+    String recordName = recordClass.getCanonicalName();
+    if (recordName == null) {
+      recordName = recordClass.getName();
+    }
+    String componentName = component.getName();
+    if (recordName.startsWith(this.removePrefix)) {
+      recordName = recordName.substring(this.removePrefixLen);
+      if (recordName.isEmpty()) {
+        return componentName;
+      }
+    }
+    return recordName + "." + componentName;
+  }
+
+  private <T extends java.lang.Record> T injectPreferences(
+      Class<? extends T> clazz, T configWithDefaults) throws ReflectiveOperationException {
+    var components = clazz.getRecordComponents();
+    Object[] params = new Object[components.length];
+    Class<?>[] types = new Class[components.length];
+    int i = 0;
+    for (RecordComponent component : components) {
+      String name = component.getName();
+      Class<?> type = component.getType();
+      types[i] = type;
+
+      Object defaultValue = null;
+      String key = null;
+      boolean getDefaultValue;
+
+      PreferenceFactory factory = TYPE_TO_FACTORY.get(type);
+      if (factory == null) {
+        warn("Cannot store '%s' in Preferences; type %s is unsupported", name, type);
+        getDefaultValue = true;
+      } else {
+        key = createKey(component);
+        getDefaultValue = !Preferences.containsKey(key);
+      }
+      if (getDefaultValue) {
+        Field defaultValueField = clazz.getDeclaredField(name);
+        defaultValueField.setAccessible(true);
+        defaultValue = defaultValueField.get(configWithDefaults);
+      }
+
+      if (factory == null) {
+        params[i] = defaultValue;
+      } else if (getDefaultValue && defaultValue == null) {
+        warn("Cannot store '%s' in Preferences; default value is null", name);
+        params[i] = null;
+      } else {
+        // Fetch the value from Preferences
+        params[i] = factory.create(this, component, key, defaultValue);
+      }
+      i++;
+    }
+    return clazz.getDeclaredConstructor(types).newInstance(params);
+  }
+
+  @FunctionalInterface
+  private interface PreferenceFactory {
+    Object create(
+        PreferencesInjector injector, RecordComponent component, String key, Object defaultValue);
+  }
+
+  @FunctionalInterface
+  private interface GenericPreferenceFactory<T> {
+    T create(PreferencesInjector injector, RecordComponent component, String key, T defaultValue);
+  }
+
+  private static final Map<Type, PreferenceFactory> TYPE_TO_FACTORY = new HashMap<>();
+
+  @SuppressWarnings("unchecked")
+  private static <T> void register(Class<T> type, GenericPreferenceFactory<T> simpleFactory) {
+    PreferenceFactory factory =
+        (injector, component, key, defaultValue) ->
+            simpleFactory.create(injector, component, key, (T) defaultValue);
+    TYPE_TO_FACTORY.put(type, factory);
+  }
+
+  static {
+    register(Boolean.TYPE, PreferencesInjector::booleanFactory);
+    register(BooleanSupplier.class, PreferencesInjector::booleanSupplierFactory);
+    register(Integer.TYPE, PreferencesInjector::intFactory);
+    register(IntSupplier.class, PreferencesInjector::intSupplierFactory);
+    register(Long.TYPE, PreferencesInjector::longFactory);
+    register(LongSupplier.class, PreferencesInjector::longSupplierFactory);
+    register(Double.TYPE, PreferencesInjector::doubleFactory);
+    register(DoubleSupplier.class, PreferencesInjector::doubleSupplierFactory);
+    register(String.class, PreferencesInjector::stringFactory);
+    register(Supplier.class, PreferencesInjector::supplierFactory);
+  }
+
+  /** Maps the generic types supported by Preferences to their primitive types. */
+  private static final Map<Type, Type> WRAPPER_TO_PRIMITIVE =
+      Map.of(
+          Boolean.class, Boolean.TYPE,
+          Integer.class, Integer.TYPE,
+          Long.class, Long.TYPE,
+          Double.class, Double.TYPE,
+          String.class, String.class);
+
+  /** Gets a boolean value from Preferences for the given component. */
+  private static boolean booleanFactory(
+      PreferencesInjector injector, RecordComponent component, String key, Boolean defaultValue) {
+    if (defaultValue != null) {
+      Preferences.initBoolean(key, defaultValue);
+    }
+    return Preferences.getBoolean(key, false);
+  }
+
+  /** Gets a BooleanSupplier value from Preferences for the given component. */
+  private static BooleanSupplier booleanSupplierFactory(
+      PreferencesInjector injector,
+      RecordComponent component,
+      String key,
+      BooleanSupplier defaultValueSupplier) {
+    if (defaultValueSupplier != null) {
+      boolean defaultValue = defaultValueSupplier.getAsBoolean();
+      Preferences.initBoolean(key, defaultValue);
+    }
+    return () -> Preferences.getBoolean(key, false);
+  }
+
+  /** Gets an int value from Preferences for the given component. */
+  private static int intFactory(
+      PreferencesInjector injector, RecordComponent component, String key, Integer defaultValue) {
+    if (defaultValue != null) {
+      Preferences.initInt(key, defaultValue);
+    }
+    return Preferences.getInt(key, 0);
+  }
+
+  /** Gets a IntSupplier value from Preferences for the given component. */
+  private static IntSupplier intSupplierFactory(
+      PreferencesInjector injector,
+      RecordComponent component,
+      String key,
+      IntSupplier defaultValueSupplier) {
+    if (defaultValueSupplier != null) {
+      int defaultValue = defaultValueSupplier.getAsInt();
+      Preferences.initInt(key, defaultValue);
+    }
+    return () -> Preferences.getInt(key, 0);
+  }
+
+  /** Gets a long value from Preferences for the given component. */
+  private static long longFactory(
+      PreferencesInjector injector, RecordComponent component, String key, Long defaultValue) {
+    if (defaultValue != null) {
+      Preferences.initLong(key, defaultValue);
+    }
+    return Preferences.getLong(key, 0);
+  }
+
+  /** Gets a LongSupplier value from Preferences for the given component. */
+  private static LongSupplier longSupplierFactory(
+      PreferencesInjector injector,
+      RecordComponent component,
+      String key,
+      LongSupplier defaultValueSupplier) {
+    if (defaultValueSupplier != null) {
+      long defaultValue = defaultValueSupplier.getAsLong();
+      Preferences.initLong(key, defaultValue);
+    }
+    return () -> Preferences.getLong(key, 0);
+  }
+
+  /** Gets a double value from Preferences for the given component. */
+  private static double doubleFactory(
+      PreferencesInjector injector, RecordComponent component, String key, Double defaultValue) {
+    if (defaultValue != null) {
+      Preferences.initDouble(key, defaultValue);
+    }
+    return Preferences.getDouble(key, 0);
+  }
+
+  /** Gets a DoubleSupplier value from Preferences for the given component. */
+  private static DoubleSupplier doubleSupplierFactory(
+      PreferencesInjector injector,
+      RecordComponent component,
+      String key,
+      DoubleSupplier defaultValueSupplier) {
+    if (defaultValueSupplier != null) {
+      double defaultValue = defaultValueSupplier.getAsDouble();
+      Preferences.initDouble(key, defaultValue);
+    }
+    return () -> Preferences.getDouble(key, 0);
+  }
+
+  /** Gets a String value from Preferences for the given component. */
+  private static String stringFactory(
+      PreferencesInjector injector, RecordComponent component, String key, String defaultValue) {
+    if (defaultValue != null) {
+      Preferences.initString(key, defaultValue);
+    }
+    return Preferences.getString(key, "");
+  }
+
+  /**
+   * Gets a Supplier value from Preferences for the given component. Supports String, long, int,
+   * boolean and float values.
+   */
+  private static Supplier<?> supplierFactory(
+      PreferencesInjector injector,
+      RecordComponent component,
+      String key,
+      Supplier<?> defaultValueSupplier) {
+    Type supplierType =
+        ((ParameterizedType) component.getGenericType()).getActualTypeArguments()[0];
+    Type supplierPrimativeType = WRAPPER_TO_PRIMITIVE.get(supplierType);
+    if (supplierPrimativeType == null) {
+      injector.warn(
+          "Cannot store '%s' in Preferences; type %s is unsupported",
+          component.getName(), component.getGenericType());
+      return defaultValueSupplier;
+    }
+    PreferenceFactory factory = TYPE_TO_FACTORY.get(supplierPrimativeType);
+
+    if (defaultValueSupplier != null) {
+      Object defaultValue = defaultValueSupplier.get();
+      if (defaultValue == null) {
+        injector.warn(
+            "Cannot store '%s' in Preferences; default value is null", component.getName());
+        return defaultValueSupplier;
+      }
+      factory.create(
+          injector, component, key, defaultValue); // Call Preferences.init{String,Double,etc}()
+    }
+    return () -> factory.create(injector, component, key, null);
+  }
+
+  private void warn(String format, Object... args) {
+    String message = String.format("WARNING: " + format, args);
+    errorReporter.accept(message);
+  }
+}
diff --git a/lib/src/test/java/com/team2813/lib2813/preferences/IsolatedPreferences.java b/lib/src/test/java/com/team2813/lib2813/preferences/IsolatedPreferences.java
new file mode 100644
index 00000000..e7544411
--- /dev/null
+++ b/lib/src/test/java/com/team2813/lib2813/preferences/IsolatedPreferences.java
@@ -0,0 +1,34 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.networktables.NetworkTableInstance;
+import edu.wpi.first.wpilibj.Preferences;
+import org.junit.rules.ExternalResource;
+
+/**
+ * A JUnit rule that ensures that changes to preferences done by a test are not leaked out to other
+ * tests.
+ */
+public final class IsolatedPreferences extends ExternalResource {
+  private NetworkTableInstance tempInstance;
+
+  /** Gets the {@link NetworkTable} that contains the preference values. */
+  public NetworkTable getPreferencesTable() {
+    return tempInstance.getTable("Preferences");
+  }
+
+  @Override
+  protected void before() {
+    tempInstance = NetworkTableInstance.create();
+    Preferences.setNetworkTableInstance(tempInstance);
+  }
+
+  @Override
+  protected void after() {
+    try {
+      Preferences.setNetworkTableInstance(NetworkTableInstance.getDefault());
+    } finally {
+      tempInstance.close();
+    }
+  }
+}
diff --git a/lib/src/test/java/com/team2813/lib2813/preferences/PreferencesInjectorTest.java b/lib/src/test/java/com/team2813/lib2813/preferences/PreferencesInjectorTest.java
new file mode 100644
index 00000000..9f621b4c
--- /dev/null
+++ b/lib/src/test/java/com/team2813/lib2813/preferences/PreferencesInjectorTest.java
@@ -0,0 +1,514 @@
+package com.team2813.lib2813.preferences;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.wpilibj.Preferences;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.*;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.runners.Enclosed;
+import org.junit.rules.ErrorCollector;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+/** Tests for {@link PreferencesInjector}. */
+@RunWith(Enclosed.class)
+public final class PreferencesInjectorTest {
+  private static final double EPSILON = 0.001;
+
+  @RunWith(Parameterized.class)
+  public static class BooleanPreferencesTest extends PreferencesInjectorTestCase {
+    final boolean defaultValue;
+    final String booleanValueKey = keyForFieldName(RecordWithBooleans.class, "booleanValue");
+    final String booleanSupplierKey = keyForFieldName(RecordWithBooleans.class, "booleanSupplier");
+    final String booleanBooleanKey = keyForFieldName(RecordWithBooleans.class, "supplierBoolean");
+    final Set<String> allKeys = Set.of(booleanValueKey, booleanSupplierKey, booleanBooleanKey);
+
+    @Parameters(name = "defaultValue={0}")
+    public static Object[] data() {
+      return new Object[] {true, false};
+    }
+
+    public BooleanPreferencesTest(boolean defaultValue) {
+      this.defaultValue = defaultValue;
+    }
+
+    /** Test record for testing classes that contain boolean fields. */
+    record RecordWithBooleans(
+        boolean booleanValue, BooleanSupplier booleanSupplier, Supplier<Boolean> supplierBoolean) {
+
+      public RecordWithBooleans(boolean defaultValue) {
+        this(defaultValue, () -> defaultValue, () -> Boolean.valueOf(defaultValue));
+      }
+    }
+
+    @Test
+    public void withoutExistingPreferences() {
+      // Arrange
+      var recordWithDefaults = new RecordWithBooleans(defaultValue);
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.booleanValue()).isEqualTo(defaultValue);
+      assertThat(recordWithPreferences.booleanSupplier().getAsBoolean()).isEqualTo(defaultValue);
+      assertThat(recordWithPreferences.supplierBoolean().get())
+          .isEqualTo(Boolean.valueOf(defaultValue));
+
+      // Assert: Default values set
+      assertThat(preferenceKeys())
+          .containsExactly(booleanValueKey, booleanSupplierKey, booleanBooleanKey);
+      for (String key : allKeys) {
+        assertThat(Preferences.getBoolean(key, !defaultValue)).isEqualTo(defaultValue);
+      }
+
+      // Arrange: Update preferences
+      boolean configuredValue = !defaultValue;
+      for (String key : allKeys) {
+        Preferences.setBoolean(key, configuredValue);
+      }
+      var preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.booleanValue()).isEqualTo(configuredValue);
+      assertThat(newRecordWithPreferences.booleanSupplier().getAsBoolean())
+          .isEqualTo(configuredValue);
+      assertThat(recordWithPreferences.booleanSupplier().getAsBoolean()).isEqualTo(configuredValue);
+      assertThat(newRecordWithPreferences.supplierBoolean().get()).isEqualTo(configuredValue);
+      assertThat(recordWithPreferences.supplierBoolean().get()).isEqualTo(configuredValue);
+      assertHasNoChangesSince(preferenceValues);
+    }
+
+    @Test
+    public void withExistingPreferences() {
+      // Arrange
+      var recordWithDefaults = new RecordWithBooleans(defaultValue);
+      boolean configuredValue = !defaultValue;
+      for (String key : allKeys) {
+        Preferences.setBoolean(key, configuredValue);
+      }
+      var preferenceValues = preferenceValues();
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.booleanValue()).isEqualTo(configuredValue);
+      assertThat(recordWithPreferences.booleanSupplier().getAsBoolean()).isEqualTo(configuredValue);
+      assertThat(recordWithPreferences.supplierBoolean().get())
+          .isEqualTo(Boolean.valueOf(configuredValue));
+      assertHasNoChangesSince(preferenceValues);
+
+      // Arrange: Update preferences
+      configuredValue = defaultValue;
+      for (String key : allKeys) {
+        Preferences.setBoolean(key, configuredValue);
+      }
+      preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.booleanValue()).isEqualTo(configuredValue);
+      assertThat(newRecordWithPreferences.booleanSupplier().getAsBoolean())
+          .isEqualTo(configuredValue);
+      assertThat(recordWithPreferences.booleanSupplier().getAsBoolean()).isEqualTo(configuredValue);
+      assertThat(newRecordWithPreferences.supplierBoolean().get()).isEqualTo(configuredValue);
+      assertThat(recordWithPreferences.supplierBoolean().get()).isEqualTo(configuredValue);
+      assertHasNoChangesSince(preferenceValues);
+    }
+  }
+
+  public static class IntPreferencesTest extends PreferencesInjectorTestCase {
+    final String intValueKey = keyForFieldName(RecordWithInts.class, "intValue");
+    final String intSupplierKey = keyForFieldName(RecordWithInts.class, "intSupplier");
+    final String supplierIntKey = keyForFieldName(RecordWithInts.class, "supplierInt");
+
+    /** Test record for testing classes that contain int fields. */
+    record RecordWithInts(int intValue, IntSupplier intSupplier, Supplier<Integer> supplierInt) {
+
+      RecordWithInts(int intValue, int intSupplierValue, int supplierIntValue) {
+        this(intValue, () -> intSupplierValue, () -> supplierIntValue);
+      }
+    }
+
+    @Test
+    public void withoutExistingPreferences() {
+      // Arrange
+      var recordWithDefaults = new RecordWithInts(1, 2, 3);
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.intValue()).isEqualTo(1);
+      assertThat(recordWithPreferences.intSupplier.getAsInt()).isEqualTo(2);
+      assertThat(recordWithPreferences.supplierInt().get()).isEqualTo(Integer.valueOf(3));
+
+      // Assert: Default values set
+      assertThat(preferenceKeys()).containsExactly(intValueKey, intSupplierKey, supplierIntKey);
+      assertThat(Preferences.getInt(intValueKey, -1)).isEqualTo(1);
+      assertThat(Preferences.getInt(intSupplierKey, -1)).isEqualTo(2);
+      assertThat(Preferences.getInt(supplierIntKey, -1)).isEqualTo(3);
+
+      // Arrange: Update preferences
+      Preferences.setInt(intValueKey, 101);
+      Preferences.setInt(intSupplierKey, 102);
+      Preferences.setInt(supplierIntKey, 103);
+      var preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.intValue()).isEqualTo(101);
+      assertThat(recordWithPreferences.intSupplier.getAsInt()).isEqualTo(102);
+      assertThat(newRecordWithPreferences.intSupplier.getAsInt()).isEqualTo(102);
+      assertThat(recordWithPreferences.supplierInt().get()).isEqualTo(Integer.valueOf(103));
+      assertThat(newRecordWithPreferences.supplierInt().get()).isEqualTo(Integer.valueOf(103));
+      assertHasNoChangesSince(preferenceValues);
+    }
+
+    @Test
+    public void withExistingPreferences() {
+      // Arrange
+      Preferences.setInt(intValueKey, 201);
+      Preferences.setInt(intSupplierKey, 202);
+      Preferences.setInt(supplierIntKey, 203);
+      var preferenceValues = preferenceValues();
+      var recordWithDefaults = new RecordWithInts(-1, -2, -3);
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.intValue()).isEqualTo(201);
+      assertThat(recordWithPreferences.intSupplier.getAsInt()).isEqualTo(202);
+      assertThat(recordWithPreferences.supplierInt().get()).isEqualTo(Integer.valueOf(203));
+      assertHasNoChangesSince(preferenceValues);
+
+      // Arrange: Update preferences
+      Preferences.setInt(intValueKey, 301);
+      Preferences.setInt(intSupplierKey, 302);
+      Preferences.setInt(supplierIntKey, 303);
+      preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.intValue()).isEqualTo(301);
+      assertThat(recordWithPreferences.intSupplier.getAsInt()).isEqualTo(302);
+      assertThat(newRecordWithPreferences.intSupplier.getAsInt()).isEqualTo(302);
+      assertThat(recordWithPreferences.supplierInt().get()).isEqualTo(Integer.valueOf(303));
+      assertThat(newRecordWithPreferences.supplierInt().get()).isEqualTo(Integer.valueOf(303));
+      assertHasNoChangesSince(preferenceValues);
+    }
+  }
+
+  public static class LongPreferencesTest extends PreferencesInjectorTestCase {
+    final String longValueKey = keyForFieldName(RecordWithLongs.class, "longValue");
+    final String longSupplierKey = keyForFieldName(RecordWithLongs.class, "longSupplier");
+    final String supplierLongKey = keyForFieldName(RecordWithLongs.class, "supplierLong");
+
+    /** Test record for testing classes that contain long fields. */
+    record RecordWithLongs(long longValue, LongSupplier longSupplier, Supplier<Long> supplierLong) {
+
+      RecordWithLongs(long longValue, long longSupplierValue, long supplierLongValue) {
+        this(longValue, () -> longSupplierValue, () -> supplierLongValue);
+      }
+    }
+
+    @Test
+    public void withoutExistingPreferences() {
+      // Arrange
+      var recordWithDefaults = new RecordWithLongs(1, 2, 3);
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.longValue()).isEqualTo(1);
+      assertThat(recordWithPreferences.longSupplier.getAsLong()).isEqualTo(2);
+      assertThat(recordWithPreferences.supplierLong().get()).isEqualTo(Long.valueOf(3));
+
+      // Assert: Default values set
+      assertThat(preferenceKeys()).containsExactly(longValueKey, longSupplierKey, supplierLongKey);
+      assertThat(Preferences.getLong(longValueKey, -1)).isEqualTo(1);
+      assertThat(Preferences.getLong(longSupplierKey, -1)).isEqualTo(2);
+      assertThat(Preferences.getLong(supplierLongKey, -1)).isEqualTo(3);
+
+      // Arrange: Update preferences
+      Preferences.setLong(longValueKey, 101);
+      Preferences.setLong(longSupplierKey, 102);
+      Preferences.setLong(supplierLongKey, 103);
+      var preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.longValue()).isEqualTo(101);
+      assertThat(recordWithPreferences.longSupplier.getAsLong()).isEqualTo(102);
+      assertThat(newRecordWithPreferences.longSupplier.getAsLong()).isEqualTo(102);
+      assertThat(recordWithPreferences.supplierLong().get()).isEqualTo(Long.valueOf(103));
+      assertThat(newRecordWithPreferences.supplierLong().get()).isEqualTo(Long.valueOf(103));
+      assertHasNoChangesSince(preferenceValues);
+    }
+
+    @Test
+    public void withExistingPreferences() {
+      // Arrange
+      Preferences.setLong(longValueKey, 201);
+      Preferences.setLong(longSupplierKey, 202);
+      Preferences.setLong(supplierLongKey, 203);
+      var preferenceValues = preferenceValues();
+      var recordWithDefaults = new RecordWithLongs(-1, -2, -3);
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.longValue()).isEqualTo(201);
+      assertThat(recordWithPreferences.longSupplier.getAsLong()).isEqualTo(202);
+      assertThat(recordWithPreferences.supplierLong().get()).isEqualTo(Long.valueOf(203));
+      assertHasNoChangesSince(preferenceValues);
+
+      // Arrange: Update preferences
+      Preferences.setLong(longValueKey, 301);
+      Preferences.setLong(longSupplierKey, 302);
+      Preferences.setLong(supplierLongKey, 303);
+      preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.longValue()).isEqualTo(301);
+      assertThat(recordWithPreferences.longSupplier.getAsLong()).isEqualTo(302);
+      assertThat(newRecordWithPreferences.longSupplier.getAsLong()).isEqualTo(302);
+      assertThat(recordWithPreferences.supplierLong().get()).isEqualTo(Long.valueOf(303));
+      assertThat(newRecordWithPreferences.supplierLong().get()).isEqualTo(Long.valueOf(303));
+      assertHasNoChangesSince(preferenceValues);
+    }
+  }
+
+  public static class DoublePreferencesTest extends PreferencesInjectorTestCase {
+    final String doubleValueKey = keyForFieldName(RecordWithDoubles.class, "doubleValue");
+    final String doubleSupplierKey = keyForFieldName(RecordWithDoubles.class, "doubleSupplier");
+    final String supplierDoubleKey = keyForFieldName(RecordWithDoubles.class, "supplierDouble");
+
+    /** Test record for testing classes that contain double fields. */
+    record RecordWithDoubles(
+        double doubleValue, DoubleSupplier doubleSupplier, Supplier<Double> supplierDouble) {
+
+      RecordWithDoubles(
+          double doubleValue, double doubleSupplierValue, double supplierDoubleValue) {
+        this(doubleValue, () -> doubleSupplierValue, () -> supplierDoubleValue);
+      }
+    }
+
+    @Test
+    public void withoutExistingPreferences() {
+      // Arrange
+      var recordWithDefaults = new RecordWithDoubles(3.14159, 2.71828, 6.28318);
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.doubleValue()).isWithin(EPSILON).of(3.14159);
+      assertThat(recordWithPreferences.doubleSupplier.getAsDouble()).isWithin(EPSILON).of(2.71828);
+      assertThat(recordWithPreferences.supplierDouble().get()).isWithin(EPSILON).of(6.28318);
+
+      // Assert: Default values set
+      assertThat(preferenceKeys())
+          .containsExactly(doubleValueKey, doubleSupplierKey, supplierDoubleKey);
+      assertThat(Preferences.getDouble(doubleValueKey, -1)).isWithin(EPSILON).of(3.14159);
+      assertThat(Preferences.getDouble(doubleSupplierKey, -1)).isWithin(EPSILON).of(2.71828);
+      assertThat(Preferences.getDouble(supplierDoubleKey, -1)).isWithin(EPSILON).of(6.28318);
+
+      // Arrange: Update preferences
+      Preferences.setDouble(doubleValueKey, 1.23);
+      Preferences.setDouble(doubleSupplierKey, 4.56);
+      Preferences.setDouble(supplierDoubleKey, 7.89);
+      var preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.doubleValue()).isWithin(EPSILON).of(1.23);
+      assertThat(recordWithPreferences.doubleSupplier.getAsDouble()).isWithin(EPSILON).of(4.56);
+      assertThat(newRecordWithPreferences.doubleSupplier.getAsDouble()).isWithin(EPSILON).of(4.56);
+      assertThat(recordWithPreferences.supplierDouble().get()).isWithin(EPSILON).of(7.89);
+      assertThat(newRecordWithPreferences.supplierDouble().get()).isWithin(EPSILON).of(7.89);
+      assertHasNoChangesSince(preferenceValues);
+    }
+
+    @Test
+    public void withExistingPreferences() {
+      // Arrange
+      Preferences.setDouble(doubleValueKey, 3.1415990);
+      Preferences.setDouble(doubleSupplierKey, 2.71828);
+      Preferences.setDouble(supplierDoubleKey, 6.28318);
+      var preferenceValues = preferenceValues();
+      var recordWithDefaults = new RecordWithDoubles(-1, -2, -3);
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(Preferences.getDouble(doubleValueKey, -1)).isWithin(EPSILON).of(3.14159);
+      assertThat(Preferences.getDouble(doubleSupplierKey, -1)).isWithin(EPSILON).of(2.71828);
+      assertThat(Preferences.getDouble(supplierDoubleKey, -1)).isWithin(EPSILON).of(6.28318);
+      assertHasNoChangesSince(preferenceValues);
+
+      // Arrange: Update preferences
+      Preferences.setDouble(doubleValueKey, 3.21);
+      Preferences.setDouble(doubleSupplierKey, 6.54);
+      Preferences.setDouble(supplierDoubleKey, 9.87);
+      preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.doubleValue()).isWithin(EPSILON).of(3.21);
+      assertThat(recordWithPreferences.doubleSupplier.getAsDouble()).isWithin(EPSILON).of(6.54);
+      assertThat(newRecordWithPreferences.doubleSupplier.getAsDouble()).isWithin(EPSILON).of(6.54);
+      assertThat(recordWithPreferences.supplierDouble().get()).isWithin(EPSILON).of(9.87);
+      assertThat(newRecordWithPreferences.supplierDouble().get()).isWithin(EPSILON).of(9.87);
+      assertHasNoChangesSince(preferenceValues);
+    }
+  }
+
+  public static class StringPreferencesTest extends PreferencesInjectorTestCase {
+    final String stringValueKey = keyForFieldName(RecordWithStrings.class, "stringValue");
+    final String stringSupplierKey = keyForFieldName(RecordWithStrings.class, "stringSupplier");
+
+    record RecordWithStrings(String stringValue, Supplier<String> stringSupplier) {
+
+      RecordWithStrings(String stringValue, String stringSupplierValue) {
+        this(stringValue, () -> stringSupplierValue);
+      }
+    }
+
+    @Test
+    public void withoutExistingPreferences() {
+      // Arrange
+      var recordWithDefaults = new RecordWithStrings("chicken", "bus");
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.stringValue()).isEqualTo("chicken");
+      assertThat(recordWithPreferences.stringSupplier().get()).isEqualTo("bus");
+
+      // Assert: Default values set
+      assertThat(preferenceKeys()).containsExactly(stringValueKey, stringSupplierKey);
+      assertThat(Preferences.getString(stringValueKey, "")).isEqualTo("chicken");
+      assertThat(Preferences.getString(stringSupplierKey, "")).isEqualTo("bus");
+
+      // Arrange: Update preferences
+      Preferences.setString(stringValueKey, "Gear");
+      Preferences.setString(stringSupplierKey, "Heads");
+      var preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.stringValue()).isEqualTo("Gear");
+      assertThat(recordWithPreferences.stringSupplier.get()).isEqualTo("Heads");
+      assertThat(newRecordWithPreferences.stringSupplier.get()).isEqualTo("Heads");
+      assertHasNoChangesSince(preferenceValues);
+    }
+
+    @Test
+    public void withExistingPreferences() {
+      // Arrange
+      Preferences.initString(stringValueKey, "chicken");
+      Preferences.initString(stringSupplierKey, "bus");
+      var preferenceValues = preferenceValues();
+      var recordWithDefaults = new RecordWithStrings("default1", "default2");
+
+      // Act
+      var recordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(recordWithPreferences.stringValue()).isEqualTo("chicken");
+      assertThat(recordWithPreferences.stringSupplier().get()).isEqualTo("bus");
+      assertHasNoChangesSince(preferenceValues);
+
+      // Arrange: Update preferences
+      Preferences.setString(stringValueKey, "Gear");
+      Preferences.setString(stringSupplierKey, "Heads");
+      preferenceValues = preferenceValues();
+
+      // Act
+      var newRecordWithPreferences = injector.injectPreferences(recordWithDefaults);
+
+      // Assert: Preferences injected
+      assertThat(newRecordWithPreferences.stringValue()).isEqualTo("Gear");
+      assertThat(recordWithPreferences.stringSupplier().get()).isEqualTo("Heads");
+      assertThat(newRecordWithPreferences.stringSupplier().get()).isEqualTo("Heads");
+      assertHasNoChangesSince(preferenceValues);
+    }
+  }
+
+  /** Base class for all nested classes of {@link PreferencesInjectorTest}. */
+  private abstract static class PreferencesInjectorTestCase {
+    PreferencesInjector injector;
+
+    @Rule public final IsolatedPreferences isolatedPreferences = new IsolatedPreferences();
+    @Rule public final ErrorCollector errorCollector = new ErrorCollector();
+
+    @Before
+    public void createInjector() {
+      String removePrefix = getClass().getCanonicalName() + ".";
+      injector = new PreferencesInjector(removePrefix);
+      injector.throwExceptions = true;
+      injector.errorReporter =
+          message ->
+              errorCollector.addError(
+                  new AssertionError("Unexpected warning: \"" + message + "\""));
+    }
+
+    protected static String keyForFieldName(Class<? extends Record> recordClass, String fieldName) {
+      return recordClass.getSimpleName() + "." + fieldName;
+    }
+
+    protected final void assertHasNoChangesSince(Map<String, Object> previousValues) {
+      var preferenceValues = preferenceValues();
+      assertWithMessage("Unexpected no changes to preference values")
+          .that(preferenceValues)
+          .isEqualTo(previousValues);
+    }
+
+    protected final Map<String, Object> preferenceValues() {
+      NetworkTable table = isolatedPreferences.getPreferencesTable();
+      return preferenceKeys().stream()
+          .collect(toMap(Function.identity(), key -> table.getEntry(key).getValue().getValue()));
+    }
+
+    protected final Set<String> preferenceKeys() {
+      // Preferences adds a ".type" key; we filter it out here.
+      return Preferences.getKeys().stream().filter(key -> !key.startsWith(".")).collect(toSet());
+    }
+  }
+}


Reviews:
user: login: vdikov
body: This is some really good Java magic here. 

The `PreferenceInjector` seems to add a lot of value to easily add configurations that can be surfaced via `Preferences` to Shuffleboard (or whatever we end up choosing to replace it with). The use is well illustrated with https://github.com/Prospect-Robotics/Robot2025/pull/87

Let me know when you'd be ready for a deep PR review. I left just a couple of cursory comments for now.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T00:49:12Z
user: login: kcooney
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T01:53:55Z
user: login: kcooney
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T01:54:37Z
user: login: vdikov
body: The code looks very solid and well tested, so we should commit it.

The only concern I have is that it is rather heavy on non-trivial Java constructs and it would be confusing and hard to maintain by inexperienced Java programmers.

I've prompted on a couple locations in the code for more high-level documentation and/or examples. That would ease my concern quite a bit. 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T17:00:32Z
user: login: kcooney
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T01:38:39Z
user: login: vdikov
body: This looks great! Ready to ship!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T20:08:37Z

Review comments:
diff_hunk: @@ -0,0 +1,105 @@
+package com.team2813.lib2813.preferences;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.util.stream.Collectors.toSet;
+
+import edu.wpi.first.wpilibj.Preferences;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ErrorCollector;
+
+public final class PreferenceInjectorTest {
+  final PreferenceInjector injector =
+      new PreferenceInjector("com.team2813.lib2813.preferences.PreferenceInjectorTest.");
+
+  @Rule public final IsolatedPreferences isolatedPreferences = new IsolatedPreferences();
+  @Rule public final ErrorCollector errorCollector = new ErrorCollector();
+
+  @Before
+  public void configureInjector() {
+    injector.throwExceptions = true;
+    injector.errorReporter =
+        message ->
+            errorCollector.addError(new AssertionError("Unexpected warning: \"" + message + "\""));
+  }
+
+  record ContainsBooleans(boolean first, boolean second) {}
+
+  @Test
+  public void injectsBooleans_newPreferences() {
+    // Arrange
+    var defaults = new ContainsBooleans(true, false);
+
+    // Act
+    ContainsBooleans injected = injector.injectPreferences(defaults);
+
+    // Assert
+    assertThat(injected).isEqualTo(defaults);
+    String key1 = "ContainsBooleans.first";
+    String key2 = "ContainsBooleans.second";
+    assertThat(preferenceKeys()).containsExactly(key1, key2);
+    boolean value = Preferences.getBoolean(key1, false);
+    assertThat(value).isTrue();
+    value = Preferences.getBoolean(key2, true);
+    assertThat(value).isFalse();
path: lib/src/test/java/com/team2813/lib2813/preferences/PreferenceInjectorTest.java
user: login: vdikov
body: I think these asserts could be compacted in a form that's even slightly more readable:


```
    assertThat(Preferences.getBoolean(key1, false)).isTrue();
    assertThat(Preferences.getBoolean(key2, true)).isFalse();
``` 


Same for the lines 61-64 below.
created_at: 2025-03-07T00:31:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
+  public static final PreferenceInjector DEFAULT_INSTANCE = new PreferenceInjector("com.team2813.");
+  private final String removePrefix;
+  private final int removePrefixLen;
+
+  // The below package-scope fields are for the self-tests.
+  boolean throwExceptions = false;
+  Consumer<String> errorReporter = DataLogManager::log;
+
+  public PreferenceInjector(String removePrefix) {
+    this.removePrefix = removePrefix;
+    this.removePrefixLen = removePrefix.length();
+  }
+
+  /**
+   * Creates an instance of the given record class with all fields populated from preferences.
+   *
+   * <p>To be stored in preferences, the type of the record components can be any of the following:
+   *
+   * <ul>
+   *   <li>{@code boolean} or {@code BooleanSupplier} or {@code Supplier<Boolean>}
+   *   <li>{@code int} or {@code IntSupplier} or {@code Supplier<Integer>}
+   *   <li>{@code long} or {@code LongSupplier} or {@code Supplier<Long>}
+   *   <li>{@code double} or {@code DoubleSupplier} or {@code Supplier<Double>}
+   *   <li>{@code String} or {@code Supplier<String>}
+   * </ul>
+   *
+   * <p>The values for the components for the passed-in instance will be used as the default value
+   * for the preference. If a component is a supplier, the supplier will be called at most once to
+   * get the default instance. Suppliers cannot return {@code null}.
+   *
+   * @param configWithDefaults Record instance with all values set to their preferred default
+   *     values.
+   */
+  public final <T extends java.lang.Record> T injectPreferences(T configWithDefaults) {
+    @SuppressWarnings("unchecked")
+    Class<? extends T> clazz = (Class<? extends T>) configWithDefaults.getClass();
+
+    try {
+      return injectPreferences(clazz, configWithDefaults);
+    } catch (ReflectiveOperationException e) {
+      if (throwExceptions) {
+        throw new RuntimeException(e); // For self-tests.
+      }
+      DriverStation.reportWarning(
+          String.format("Could not inject preferences into %s: %s", clazz.getSimpleName(), e),
+          e.getStackTrace());
+      return configWithDefaults;
+    }
+  }
+
+  /** Creates a preference key for the given record component. */
+  protected String createKey(RecordComponent component) {
+    String prefix = component.getDeclaringRecord().getCanonicalName();
+    String name = component.getName();
+    if (prefix.startsWith(this.removePrefix)) {
+      prefix = prefix.substring(this.removePrefixLen);
+      if (prefix.isEmpty()) {
+        return name;
+      }
+    }
+    return prefix + "." + name;
+  }
+
+  private <T extends java.lang.Record> T injectPreferences(
+      Class<? extends T> clazz, T configWithDefaults) throws ReflectiveOperationException {
+    var components = clazz.getRecordComponents();
+    Object[] params = new Object[components.length];
+    Class<?>[] types = new Class[components.length];
+    int i = 0;
+    for (RecordComponent component : clazz.getRecordComponents()) {
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: vdikov
body: replace `clazz.getRecordComponents()` with `components`.
created_at: 2025-03-07T00:38:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,105 @@
+package com.team2813.lib2813.preferences;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.util.stream.Collectors.toSet;
+
+import edu.wpi.first.wpilibj.Preferences;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ErrorCollector;
+
+public final class PreferenceInjectorTest {
+  final PreferenceInjector injector =
+      new PreferenceInjector("com.team2813.lib2813.preferences.PreferenceInjectorTest.");
+
+  @Rule public final IsolatedPreferences isolatedPreferences = new IsolatedPreferences();
+  @Rule public final ErrorCollector errorCollector = new ErrorCollector();
+
+  @Before
+  public void configureInjector() {
+    injector.throwExceptions = true;
+    injector.errorReporter =
+        message ->
+            errorCollector.addError(new AssertionError("Unexpected warning: \"" + message + "\""));
+  }
+
+  record ContainsBooleans(boolean first, boolean second) {}
+
+  @Test
+  public void injectsBooleans_newPreferences() {
+    // Arrange
+    var defaults = new ContainsBooleans(true, false);
+
+    // Act
+    ContainsBooleans injected = injector.injectPreferences(defaults);
+
+    // Assert
+    assertThat(injected).isEqualTo(defaults);
+    String key1 = "ContainsBooleans.first";
+    String key2 = "ContainsBooleans.second";
+    assertThat(preferenceKeys()).containsExactly(key1, key2);
+    boolean value = Preferences.getBoolean(key1, false);
+    assertThat(value).isTrue();
+    value = Preferences.getBoolean(key2, true);
+    assertThat(value).isFalse();
path: lib/src/test/java/com/team2813/lib2813/preferences/PreferenceInjectorTest.java
user: login: kcooney
body: Done.
created_at: 2025-03-07T01:53:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
+  public static final PreferenceInjector DEFAULT_INSTANCE = new PreferenceInjector("com.team2813.");
+  private final String removePrefix;
+  private final int removePrefixLen;
+
+  // The below package-scope fields are for the self-tests.
+  boolean throwExceptions = false;
+  Consumer<String> errorReporter = DataLogManager::log;
+
+  public PreferenceInjector(String removePrefix) {
+    this.removePrefix = removePrefix;
+    this.removePrefixLen = removePrefix.length();
+  }
+
+  /**
+   * Creates an instance of the given record class with all fields populated from preferences.
+   *
+   * <p>To be stored in preferences, the type of the record components can be any of the following:
+   *
+   * <ul>
+   *   <li>{@code boolean} or {@code BooleanSupplier} or {@code Supplier<Boolean>}
+   *   <li>{@code int} or {@code IntSupplier} or {@code Supplier<Integer>}
+   *   <li>{@code long} or {@code LongSupplier} or {@code Supplier<Long>}
+   *   <li>{@code double} or {@code DoubleSupplier} or {@code Supplier<Double>}
+   *   <li>{@code String} or {@code Supplier<String>}
+   * </ul>
+   *
+   * <p>The values for the components for the passed-in instance will be used as the default value
+   * for the preference. If a component is a supplier, the supplier will be called at most once to
+   * get the default instance. Suppliers cannot return {@code null}.
+   *
+   * @param configWithDefaults Record instance with all values set to their preferred default
+   *     values.
+   */
+  public final <T extends java.lang.Record> T injectPreferences(T configWithDefaults) {
+    @SuppressWarnings("unchecked")
+    Class<? extends T> clazz = (Class<? extends T>) configWithDefaults.getClass();
+
+    try {
+      return injectPreferences(clazz, configWithDefaults);
+    } catch (ReflectiveOperationException e) {
+      if (throwExceptions) {
+        throw new RuntimeException(e); // For self-tests.
+      }
+      DriverStation.reportWarning(
+          String.format("Could not inject preferences into %s: %s", clazz.getSimpleName(), e),
+          e.getStackTrace());
+      return configWithDefaults;
+    }
+  }
+
+  /** Creates a preference key for the given record component. */
+  protected String createKey(RecordComponent component) {
+    String prefix = component.getDeclaringRecord().getCanonicalName();
+    String name = component.getName();
+    if (prefix.startsWith(this.removePrefix)) {
+      prefix = prefix.substring(this.removePrefixLen);
+      if (prefix.isEmpty()) {
+        return name;
+      }
+    }
+    return prefix + "." + name;
+  }
+
+  private <T extends java.lang.Record> T injectPreferences(
+      Class<? extends T> clazz, T configWithDefaults) throws ReflectiveOperationException {
+    var components = clazz.getRecordComponents();
+    Object[] params = new Object[components.length];
+    Class<?>[] types = new Class[components.length];
+    int i = 0;
+    for (RecordComponent component : clazz.getRecordComponents()) {
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: kcooney
body: Done.
created_at: 2025-03-07T01:54:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
+  public static final PreferenceInjector DEFAULT_INSTANCE = new PreferenceInjector("com.team2813.");
+  private final String removePrefix;
+  private final int removePrefixLen;
+
+  // The below package-scope fields are for the self-tests.
+  boolean throwExceptions = false;
+  Consumer<String> errorReporter = DataLogManager::log;
+
+  public PreferenceInjector(String removePrefix) {
+    this.removePrefix = removePrefix;
+    this.removePrefixLen = removePrefix.length();
+  }
+
+  /**
+   * Creates an instance of the given record class with all fields populated from preferences.
+   *
+   * <p>To be stored in preferences, the type of the record components can be any of the following:
+   *
+   * <ul>
+   *   <li>{@code boolean} or {@code BooleanSupplier} or {@code Supplier<Boolean>}
+   *   <li>{@code int} or {@code IntSupplier} or {@code Supplier<Integer>}
+   *   <li>{@code long} or {@code LongSupplier} or {@code Supplier<Long>}
+   *   <li>{@code double} or {@code DoubleSupplier} or {@code Supplier<Double>}
+   *   <li>{@code String} or {@code Supplier<String>}
+   * </ul>
+   *
+   * <p>The values for the components for the passed-in instance will be used as the default value
+   * for the preference. If a component is a supplier, the supplier will be called at most once to
+   * get the default instance. Suppliers cannot return {@code null}.
+   *
+   * @param configWithDefaults Record instance with all values set to their preferred default
+   *     values.
+   */
+  public final <T extends java.lang.Record> T injectPreferences(T configWithDefaults) {
+    @SuppressWarnings("unchecked")
+    Class<? extends T> clazz = (Class<? extends T>) configWithDefaults.getClass();
+
+    try {
+      return injectPreferences(clazz, configWithDefaults);
+    } catch (ReflectiveOperationException e) {
+      if (throwExceptions) {
+        throw new RuntimeException(e); // For self-tests.
+      }
+      DriverStation.reportWarning(
+          String.format("Could not inject preferences into %s: %s", clazz.getSimpleName(), e),
+          e.getStackTrace());
+      return configWithDefaults;
+    }
+  }
+
+  /** Creates a preference key for the given record component. */
+  protected String createKey(RecordComponent component) {
+    String prefix = component.getDeclaringRecord().getCanonicalName();
+    String name = component.getName();
+    if (prefix.startsWith(this.removePrefix)) {
+      prefix = prefix.substring(this.removePrefixLen);
+      if (prefix.isEmpty()) {
+        return name;
+      }
+    }
+    return prefix + "." + name;
+  }
+
+  private <T extends java.lang.Record> T injectPreferences(
+      Class<? extends T> clazz, T configWithDefaults) throws ReflectiveOperationException {
+    var components = clazz.getRecordComponents();
+    Object[] params = new Object[components.length];
+    Class<?>[] types = new Class[components.length];
+    int i = 0;
+    for (RecordComponent component : components) {
+      String name = component.getName();
+      Class<?> type = component.getType();
+      types[i] = type;
+
+      Object defaultValue = null;
+      String key = null;
+      boolean getDefaultValue;
+
+      PreferenceFactory factory = TYPE_TO_FACTORY.get(type);
+      if (factory == null) {
+        warn("Cannot store '%s' in Preferences; type %s is unsupported", name, type);
+        getDefaultValue = true;
+      } else {
+        key = createKey(component);
+        getDefaultValue = !Preferences.containsKey(key);
+      }
+      if (getDefaultValue) {
+        Field defaultValueField = clazz.getDeclaredField(name);
+        defaultValueField.setAccessible(true);
+        defaultValue = defaultValueField.get(configWithDefaults);
+      }
+
+      if (factory == null) {
+        params[i] = defaultValue;
+      } else {
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: vdikov
body: The `else` and the `if` clauses can be folded into a `else if` and thus pop one level of nesting here.
created_at: 2025-03-07T05:21:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
+  public static final PreferenceInjector DEFAULT_INSTANCE = new PreferenceInjector("com.team2813.");
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: vdikov
body: The class members need a description, even if just a short one.

Somewhere (perhaps in a class description?), we need to explain what parts of the class names are being removed and why. A few examples of property names before and after the prefix removal might make that description short and crisp.

Ultimately, the property names (post prefix removal) is what is going to start showing up in the UI (is that right?), so readers should be able to find an documentation that explains where these names come from.
created_at: 2025-03-07T05:25:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.networktables.NetworkTableInstance;
+import edu.wpi.first.wpilibj.Preferences;
+import org.junit.rules.ExternalResource;
+
+public final class IsolatedPreferences extends ExternalResource {
path: lib/src/test/java/com/team2813/lib2813/preferences/IsolatedPreferences.java
user: login: vdikov
body: What is the purpose of `IsolatedPreferences`? This class might well deserve a class description.
created_at: 2025-03-07T05:38:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: vdikov
body: One thing I'm missing in this PR is a description on how to actually use `PreferenceInjector` in the robot code.

I'd typically either write a class or file description that mentions what's the purpose of the preference injector and also gives a minimal example code snippets[^1] or I'd refer the reader to unit-tests showing some trivial use-cases.

[^1]: Here's an example in the wild where the code owners provide mini-snippets along a function definition https://github.com/abseil/abseil-cpp/blob/master/absl/strings/escaping.h#L66-L72

The unit-test for PreferenceInjector, as is now, is exhaustive and very heavy on Java constructs, so it won't serve the needs of  providing quick examples for a novice reader to understand how to use a PreferenceInjector. 
created_at: 2025-03-07T16:56:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: kcooney
body: Done.
created_at: 2025-03-08T01:25:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
+  public static final PreferenceInjector DEFAULT_INSTANCE = new PreferenceInjector("com.team2813.");
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: kcooney
body: > The class members need a description, even if just a short one.

Done.

> Somewhere (perhaps in a class description?), we need to explain what parts of the class names are being removed and why.

I added JavaDoc to the `PreferenceInjector` constructor that explains what the `removePrefix` field does and why someone might add that.

I didn't add examples; while I want the code to be understandable, we are all contributing in our spare time, so the level of quality of the documentation will be not as high as a Google-supported open source project.
created_at: 2025-03-08T01:27:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.wpilibj.DataLogManager;
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.Preferences;
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+
+public class PreferenceInjector {
+  public static final PreferenceInjector DEFAULT_INSTANCE = new PreferenceInjector("com.team2813.");
+  private final String removePrefix;
+  private final int removePrefixLen;
+
+  // The below package-scope fields are for the self-tests.
+  boolean throwExceptions = false;
+  Consumer<String> errorReporter = DataLogManager::log;
+
+  public PreferenceInjector(String removePrefix) {
+    this.removePrefix = removePrefix;
+    this.removePrefixLen = removePrefix.length();
+  }
+
+  /**
+   * Creates an instance of the given record class with all fields populated from preferences.
+   *
+   * <p>To be stored in preferences, the type of the record components can be any of the following:
+   *
+   * <ul>
+   *   <li>{@code boolean} or {@code BooleanSupplier} or {@code Supplier<Boolean>}
+   *   <li>{@code int} or {@code IntSupplier} or {@code Supplier<Integer>}
+   *   <li>{@code long} or {@code LongSupplier} or {@code Supplier<Long>}
+   *   <li>{@code double} or {@code DoubleSupplier} or {@code Supplier<Double>}
+   *   <li>{@code String} or {@code Supplier<String>}
+   * </ul>
+   *
+   * <p>The values for the components for the passed-in instance will be used as the default value
+   * for the preference. If a component is a supplier, the supplier will be called at most once to
+   * get the default instance. Suppliers cannot return {@code null}.
+   *
+   * @param configWithDefaults Record instance with all values set to their preferred default
+   *     values.
+   */
+  public final <T extends java.lang.Record> T injectPreferences(T configWithDefaults) {
+    @SuppressWarnings("unchecked")
+    Class<? extends T> clazz = (Class<? extends T>) configWithDefaults.getClass();
+
+    try {
+      return injectPreferences(clazz, configWithDefaults);
+    } catch (ReflectiveOperationException e) {
+      if (throwExceptions) {
+        throw new RuntimeException(e); // For self-tests.
+      }
+      DriverStation.reportWarning(
+          String.format("Could not inject preferences into %s: %s", clazz.getSimpleName(), e),
+          e.getStackTrace());
+      return configWithDefaults;
+    }
+  }
+
+  /** Creates a preference key for the given record component. */
+  protected String createKey(RecordComponent component) {
+    String prefix = component.getDeclaringRecord().getCanonicalName();
+    String name = component.getName();
+    if (prefix.startsWith(this.removePrefix)) {
+      prefix = prefix.substring(this.removePrefixLen);
+      if (prefix.isEmpty()) {
+        return name;
+      }
+    }
+    return prefix + "." + name;
+  }
+
+  private <T extends java.lang.Record> T injectPreferences(
+      Class<? extends T> clazz, T configWithDefaults) throws ReflectiveOperationException {
+    var components = clazz.getRecordComponents();
+    Object[] params = new Object[components.length];
+    Class<?>[] types = new Class[components.length];
+    int i = 0;
+    for (RecordComponent component : components) {
+      String name = component.getName();
+      Class<?> type = component.getType();
+      types[i] = type;
+
+      Object defaultValue = null;
+      String key = null;
+      boolean getDefaultValue;
+
+      PreferenceFactory factory = TYPE_TO_FACTORY.get(type);
+      if (factory == null) {
+        warn("Cannot store '%s' in Preferences; type %s is unsupported", name, type);
+        getDefaultValue = true;
+      } else {
+        key = createKey(component);
+        getDefaultValue = !Preferences.containsKey(key);
+      }
+      if (getDefaultValue) {
+        Field defaultValueField = clazz.getDeclaredField(name);
+        defaultValueField.setAccessible(true);
+        defaultValue = defaultValueField.get(configWithDefaults);
+      }
+
+      if (factory == null) {
+        params[i] = defaultValue;
+      } else {
path: lib/src/main/java/com/team2813/lib2813/preferences/PreferenceInjector.java
user: login: kcooney
body: Done.
created_at: 2025-03-08T01:36:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+package com.team2813.lib2813.preferences;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.networktables.NetworkTableInstance;
+import edu.wpi.first.wpilibj.Preferences;
+import org.junit.rules.ExternalResource;
+
+public final class IsolatedPreferences extends ExternalResource {
path: lib/src/test/java/com/team2813/lib2813/preferences/IsolatedPreferences.java
user: login: kcooney
body: Done.
created_at: 2025-03-08T01:37:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: kcooney
created_at: 2025-03-06T05:08:11Z
body: Note: this is an alternative to #23
author_association: CONTRIBUTOR
user: login: kcooney
created_at: 2025-03-07T01:55:14Z
body: > Let me know when you'd be ready for a deep PR review. I left just a couple of cursory comments for now.

I'm just adding tests. The test for boolean Preferences are done.

I'll wait until doing more until we decide on which PR to drop.

author_association: CONTRIBUTOR