diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..fef6da8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,49 @@ +name: Build & Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build: + name: ./gradlew build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 (matches WPILib 2026) + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle wrapper + caches + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Build + run all tests + run: ./gradlew build --no-daemon --stacktrace + + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gradle-test-report + path: | + build/reports/tests/test/ + build/test-results/test/ + retention-days: 7 diff --git a/build.gradle b/build.gradle index 322a386..3dab7cd 100644 --- a/build.gradle +++ b/build.gradle @@ -73,11 +73,21 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.assertj:assertj-core:3.26.3' + testImplementation 'org.mockito:mockito-core:5.14.2' + testImplementation 'org.mockito:mockito-junit-jupiter:5.14.2' } test { useJUnitPlatform() systemProperty 'junit.jupiter.extensions.autodetection.enabled', 'true' + maxParallelForks = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2)) + forkEvery = 50 + testLogging { + events 'failed', 'skipped' + showStandardStreams = false + exceptionFormat = 'full' + } } // Simulation configuration (e.g. environment variables). diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/frc/robot/Binds.java b/src/main/java/frc/robot/Binds.java index afab588..aa54bd8 100644 --- a/src/main/java/frc/robot/Binds.java +++ b/src/main/java/frc/robot/Binds.java @@ -6,7 +6,6 @@ import com.ctre.phoenix6.swerve.SwerveRequest; import edu.wpi.first.wpilibj.DriverStation; import frc.robot.commands.AutoAlign; -//import frc.robot.commands.DefenseBoost; import frc.robot.commands.GyroReset; import frc.robot.commands.ShootCommand; import frc.robot.subsystem.Hopper; @@ -69,7 +68,6 @@ public static final void bind(){ HumanControls.OperatorPanel.L1.whileTrue(Intake.get().doOscilateIntake()); HumanControls.OperatorPanel.L2.whileTrue(Swerve.get().xMode()); HumanControls.OperatorPanel.L3.whileTrue(new ShootCommand(()->Field.Alliance_Find.hub)); - //HumanControls.OperatorPanel.L4.whileTrue(new DefenseBoost(120, 50)); HumanControls.OperatorPanel.Processor.whileTrue(Shooter.get().spinUpCommand()); diff --git a/src/main/java/frc/robot/NamedCommandRegistry.java b/src/main/java/frc/robot/NamedCommandRegistry.java new file mode 100644 index 0000000..bcdee22 --- /dev/null +++ b/src/main/java/frc/robot/NamedCommandRegistry.java @@ -0,0 +1,61 @@ +package frc.robot; + +import edu.wpi.first.wpilibj2.command.Command; +import frc.robot.commands.ShootCommand; +import frc.robot.subsystem.Intake; +import frc.robot.subsystem.Shooter; +import frc.robot.util.Auto; +import frc.robot.util.Field; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Single source of truth for every PathPlanner "named command" the robot exposes. + * + *
{@link Robot#Robot()} should build its {@code Auto.NamedCommand[]} from {@link #all()} (so
+ * adding a new named command happens in exactly one place), and {@link NamedCommandConsistencyTest}
+ * cross-checks {@link #NAMES} against every {@code .auto} JSON file in the deploy directory so an
+ * auto referencing a non-existent named command fails at build time instead of silently no-op'ing
+ * during a match.
+ */
+public final class NamedCommandRegistry {
+ private NamedCommandRegistry() {}
+
+ /** Every name that {@link #all()} returns, exposed as a stable set for cross-checking. */
+ public static final Set The previous implementation returned {@code (leader + follower) / 2}, but the follower is
+ * set up via {@code Follower(..., MotorAlignmentValue.Opposed)} so its signed velocity is the
+ * negation of the leader's; averaging cancelled to ~0 at speed and prevented ShootCommand
+ * from ever tripping its at-speed threshold. The fix is to use only the leader. The follower
+ * parameter is retained in the signature so the call-site documents both signals and so a
+ * future regression to averaging is locally visible. See ShooterRollerVelocityTest.
+ */
+ public static double aggregateRollerVelocity(double leaderVelocity, double followerVelocity) {
+ return leaderVelocity;
+ }
+
@Override
public void periodic() {
// Refresh status signals once per loop and cache the values to avoid
// multiple blocking refresh() calls in other methods/commands which can
// overload the CAN/communication bus and cause scheduler overruns.
- //Made with great assistance by copilot
try {
- cachedRollerVelocity = (velocity_roller.refresh().getValueAsDouble() + velocity_roller_2.refresh().getValueAsDouble()) / 2.0;
+ cachedRollerVelocity = aggregateRollerVelocity(
+ velocity_roller.refresh().getValueAsDouble(),
+ velocity_roller_2.refresh().getValueAsDouble());
} catch (RuntimeException e) {
// Be defensive: if refresh fails, keep last value and log to dashboard
SmartDashboard.putString("Shooter/VelocityRefreshError", e.getMessage());
diff --git a/src/main/java/frc/robot/subsystem/Swerve.java b/src/main/java/frc/robot/subsystem/Swerve.java
index 848d82a..1d18a34 100644
--- a/src/main/java/frc/robot/subsystem/Swerve.java
+++ b/src/main/java/frc/robot/subsystem/Swerve.java
@@ -17,7 +17,6 @@
import com.pathplanner.lib.path.PathConstraints;
import edu.wpi.first.math.Matrix;
-import edu.wpi.first.math.VecBuilder;
import edu.wpi.first.math.controller.PIDController;
import edu.wpi.first.math.geometry.Pose2d;
import edu.wpi.first.math.geometry.Rotation2d;
@@ -35,12 +34,15 @@
import edu.wpi.first.wpilibj.smartdashboard.SmartDashboard;
import edu.wpi.first.wpilibj2.command.Command;
import edu.wpi.first.wpilibj2.command.CommandScheduler;
+import edu.wpi.first.wpilibj2.command.Commands;
import edu.wpi.first.wpilibj2.command.Subsystem;
+import java.util.Set;
import frc.robot.TunerConstants;
import frc.robot.TunerConstants.TunerSwerveDrivetrain;
import frc.robot.util.BaseCam.AprilTagResult;
import frc.robot.util.Field;
import frc.robot.util.LimeLightCam;
+import frc.robot.util.VisionFilter;
public final class Swerve extends TunerSwerveDrivetrain implements Subsystem, Sendable {
private static Swerve m_Swerve;
@@ -110,27 +112,15 @@ void cameraLocalization(){
}
boolean acceptEstimate(AprilTagResult latestResult) {
- if (latestResult.distToTag > 3.5)
- return false;
- SmartDashboard.putBoolean("Accepted", false);
- if (latestResult.ambiguity > 0.7)
- return false; // Too Ambiguous, Ignore
- SmartDashboard.putBoolean("Accepted", false);
-
- if (getState().Speeds.omegaRadiansPerSecond > 1.5)
- return false; // Rotating too fast, ignore
- SmartDashboard.putBoolean("Accepted", false);
-
- if (latestResult.distToTag < 0.5) {
- setVisionMeasurementStdDevs(VecBuilder.fill(0.3, .3, 50.0));
- } else {
- setVisionMeasurementStdDevs(
- VecBuilder.fill(latestResult.ambiguity * Math.pow(latestResult.distToTag, 2)*3.0,
- latestResult.ambiguity * Math.pow(latestResult.distToTag, 2)*3.0,
- latestResult.ambiguity * Math.pow(latestResult.distToTag, 2)*3.0));
- }
- SmartDashboard.putBoolean("Accepted", true);
- return true;
+ VisionFilter.Decision decision = VisionFilter.evaluate(
+ latestResult.distToTag,
+ latestResult.ambiguity,
+ getState().Speeds.omegaRadiansPerSecond);
+ if (decision.accept()) {
+ setVisionMeasurementStdDevs(decision.stdDevs());
+ }
+ SmartDashboard.putBoolean("Accepted", decision.accept());
+ return decision.accept();
}
@@ -244,19 +234,21 @@ private void simulationInit() {
- // Since we are using a holonomic drivetrain, the rotation component of this pose
- // represents the goal holonomic rotation
- Pose2d targetPose = Field.Alliance_Find.climb_tower;
-
+ // Note: do NOT cache the climb target pose at construction time. Swerve is built before
+ // the FMS reports the alliance, so any cached Field.Alliance_Find.climb_tower would lock
+ // in the default (blue) value forever. alignToClimb() below defers the lookup until the
+ // command is scheduled (alliance known by then). See AlignToClimbTest.
PathConstraints constraints = new PathConstraints(
0.5, 0.7,
Units.degreesToRadians(5), Units.degreesToRadians(10));
public Command alignToClimb(){
- return AutoBuilder.pathfindToPose(
- targetPose,
- constraints,
- 0.0);
-}
+ return Commands.defer(
+ () -> AutoBuilder.pathfindToPose(
+ Field.Alliance_Find.climb_tower,
+ constraints,
+ 0.0),
+ Set.of(this));
+ }
}
\ No newline at end of file
diff --git a/src/main/java/frc/robot/util/Auto.java b/src/main/java/frc/robot/util/Auto.java
index d889085..6440c1f 100644
--- a/src/main/java/frc/robot/util/Auto.java
+++ b/src/main/java/frc/robot/util/Auto.java
@@ -4,7 +4,6 @@
import com.ctre.phoenix6.swerve.SwerveRequest;
import com.pathplanner.lib.auto.AutoBuilder;
import com.pathplanner.lib.auto.NamedCommands;
-import com.pathplanner.lib.commands.PathPlannerAuto;
import com.pathplanner.lib.config.PIDConstants;
import com.pathplanner.lib.config.RobotConfig;
import com.pathplanner.lib.controllers.PPHolonomicDriveController;
@@ -30,12 +29,13 @@ public static void initialize(NamedCommand... Commands) {
command.register();
if (configureAutoBuilder()) {
+ // buildAutoChooser() auto-loads every .auto file in
+ // src/main/deploy/pathplanner/autos/ by its on-disk name (including
+ // "Double Swipe Bravo Auto"). The previous explicit addOption calls
+ // added duplicate entries with mislabeled names (label "...Bravo..."
+ // silently loaded "...Alpha..."). See AutoChooserConsistencyTest.
autoChooser = AutoBuilder.buildAutoChooser();
SmartDashboard.putData("Auto Chooser", autoChooser);
-
- autoChooser.addOption("Double Swipe Bravo Auto", new PathPlannerAuto("Double Swipe Alpha Auto", true));
- autoChooser.addOption("Single Swipe Bravo Auto", new PathPlannerAuto("Single Swipe Alpha Auto", true));
- autoChooser.addOption("Double Swipe Bravo Auto Reset Odometry", new PathPlannerAuto("Double Swipe Alpha Auto Reset Odometry", true));
}
initialized = true;
diff --git a/src/main/java/frc/robot/util/Field.java b/src/main/java/frc/robot/util/Field.java
index 7f38831..0e23726 100644
--- a/src/main/java/frc/robot/util/Field.java
+++ b/src/main/java/frc/robot/util/Field.java
@@ -34,7 +34,7 @@ public class Field {
public final static Translation2d hub_position_red = new Translation2d(11.924, 4.048);
public final static Translation2d pass_position_red_1 = new Translation2d(15.48, 7.369);
public final static Translation2d pass_position_red_2 = new Translation2d(15.715, 0.740);
- public final static Pose2d climb_tower_red = new Pose2d(15.478,5201, Rotation2d.fromDegrees(0));
+ public final static Pose2d climb_tower_red = new Pose2d(15.478, 5.201, Rotation2d.fromDegrees(0));
diff --git a/src/main/java/frc/robot/util/ShooterControlPolicy.java b/src/main/java/frc/robot/util/ShooterControlPolicy.java
new file mode 100644
index 0000000..92b229a
--- /dev/null
+++ b/src/main/java/frc/robot/util/ShooterControlPolicy.java
@@ -0,0 +1,51 @@
+package frc.robot.util;
+
+import edu.wpi.first.math.interpolation.InterpolatingDoubleTreeMap;
+
+/**
+ * Pure-logic decision for the shooter:
+ *
+ * Extracted from {@link frc.robot.commands.ShootCommand} so the latch logic is testable
+ * exhaustively with no HAL or mocks. The default enter/exit tolerances are exposed as constants
+ * so {@code ShootCommand} and the test stay in lock-step.
+ */
+public final class ShooterControlPolicy {
+ private ShooterControlPolicy() {}
+
+ /** Default enter band — tight, matches the original 0.95 from ShootCommand. */
+ public static final double DEFAULT_ENTER_TOL = 0.95;
+
+ /** Default exit band — wider than enter so a brief sag doesn't drop the indexer. */
+ public static final double DEFAULT_EXIT_TOL = 1.5;
+
+ public static Decision compute(
+ double distMeters,
+ double measuredVel,
+ InterpolatingDoubleTreeMap velTable,
+ InterpolatingDoubleTreeMap indexerTable,
+ double enterTol,
+ double exitTol,
+ boolean wasFiring) {
+ double target = velTable.get(distMeters);
+ double indexer = indexerTable.get(distMeters);
+ double err = Math.abs(measuredVel - target);
+ boolean fire = wasFiring ? (err < exitTol) : (err < enterTol);
+ return new Decision(target, indexer, fire);
+ }
+
+ /**
+ * @param flywheelTarget commanded flywheel velocity (rotations/second, signed)
+ * @param indexerSpeed commanded indexer velocity (only meaningful when {@code fireIndexer})
+ * @param fireIndexer true → run the indexer + hopper; false → keep them stopped
+ */
+ public record Decision(double flywheelTarget, double indexerSpeed, boolean fireIndexer) {}
+}
diff --git a/src/main/java/frc/robot/util/VisionFilter.java b/src/main/java/frc/robot/util/VisionFilter.java
new file mode 100644
index 0000000..a5448f8
--- /dev/null
+++ b/src/main/java/frc/robot/util/VisionFilter.java
@@ -0,0 +1,60 @@
+package frc.robot.util;
+
+import edu.wpi.first.math.Matrix;
+import edu.wpi.first.math.VecBuilder;
+import edu.wpi.first.math.numbers.N1;
+import edu.wpi.first.math.numbers.N3;
+
+/**
+ * Pure-logic AprilTag vision-measurement filter, extracted from {@code Swerve.acceptEstimate} so
+ * it can be exhaustively unit-tested with no HAL and no mocks.
+ *
+ * The original implementation:
+ * This extraction preserves the original distance and ambiguity bounds exactly, and uses the
+ * absolute value of omega so a fast negative rotation is also rejected (the original
+ * comparison was a sign-sensitive {@code > 1.5}; this is the conservative fix).
+ */
+public final class VisionFilter {
+ private VisionFilter() {}
+
+ public static final double MAX_DIST_M = 3.5;
+ public static final double MAX_AMBIGUITY = 0.7;
+ public static final double MAX_ABS_OMEGA_RAD_PER_SEC = 1.5;
+ public static final double CLOSE_RANGE_DIST_M = 0.5;
+ private static final Matrix For every {@code .auto} file under {@code src/main/deploy/pathplanner/autos}, parses the JSON
+ * and collects every {@code {"type":"named","data":{"name":"X"}}} reference. Asserts every
+ * referenced name appears in {@link NamedCommandRegistry#NAMES} — the single source of truth for
+ * which named commands {@link Robot#Robot()} registers with PathPlanner.
+ *
+ * Catches the case where an auto JSON references a named command that nobody registered (or
+ * a typo'd name like {@code "Shooter"} instead of {@code "Shoot"}). Currently latent in this
+ * codebase: nothing fails at robot-init for unregistered named commands; they just silently no-op
+ * when the auto runs.
+ */
+class NamedCommandConsistencyTest {
+ private static final Path AUTOS_DIR = Path.of("src/main/deploy/pathplanner/autos");
+ private static final Path PATHS_DIR = Path.of("src/main/deploy/pathplanner/paths");
+
+ @Test
+ void every_named_command_referenced_in_an_auto_is_registered() throws IOException {
+ Set {@code Swerve} is constructed only by its no-arg singleton getter (which spins up real
+ * Phoenix6 devices); we can't mock that cleanly. Instead we mock {@code Swerve} and
+ * {@code Pigeon2} and verify {@link GyroReset#initialize()} calls {@code pigeon.setYaw(angle)}.
+ *
+ * Also verifies {@link GyroReset#isFinished()} is {@code true} (one-shot).
+ */
+class GyroResetTest {
+
+ @Test
+ void initialize_calls_setYaw_with_default_zero_angle() {
+ Swerve swerve = mock(Swerve.class, Mockito.RETURNS_DEEP_STUBS);
+ Pigeon2 pigeon = mock(Pigeon2.class);
+ when(swerve.getPigeon2()).thenReturn(pigeon);
+
+ GyroReset cmd = new GyroReset(swerve);
+ cmd.initialize();
+
+ verify(pigeon).setYaw(eq(0.0));
+ }
+
+ @Test
+ void initialize_calls_setYaw_with_specified_angle() {
+ Swerve swerve = mock(Swerve.class);
+ Pigeon2 pigeon = mock(Pigeon2.class);
+ when(swerve.getPigeon2()).thenReturn(pigeon);
+
+ GyroReset cmd = new GyroReset(swerve, 90.0);
+ cmd.initialize();
+
+ verify(pigeon).setYaw(eq(90.0));
+ }
+
+ @Test
+ void isFinished_is_true_immediately() {
+ Swerve swerve = mock(Swerve.class);
+ GyroReset cmd = new GyroReset(swerve);
+ // No HAL init needed for isFinished — it's a constant return.
+ // (Construction adds requirements; that just registers with the CommandScheduler in
+ // production but doesn't need anything special here.)
+ // Skip directly to the assertion via the contract.
+ // We intentionally do NOT call initialize here.
+ // isFinished() should be true regardless of state.
+ org.assertj.core.api.Assertions.assertThat(cmd.isFinished()).isTrue();
+ }
+}
diff --git a/src/test/java/frc/robot/subsystem/AlignToClimbTest.java b/src/test/java/frc/robot/subsystem/AlignToClimbTest.java
new file mode 100644
index 0000000..cbb3a2b
--- /dev/null
+++ b/src/test/java/frc/robot/subsystem/AlignToClimbTest.java
@@ -0,0 +1,75 @@
+package frc.robot.subsystem;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Layer B — static contract test for {@link Swerve#alignToClimb()}.
+ *
+ * The original bug: {@code Swerve} had a {@code Pose2d targetPose = Field.Alliance_Find
+ * .climb_tower} cached at class-construction time. Since {@code Swerve} is a singleton built
+ * before the FMS reports the alliance, this captured the default (blue) value. {@code
+ * alignToClimb} then passed that stale value to {@code AutoBuilder.pathfindToPose}, so on the red
+ * alliance the robot would drive to the blue climb tower.
+ *
+ * The fix is to wrap {@code alignToClimb} in {@code Commands.defer} so the lookup happens at
+ * schedule-time (after alliance is known), and to delete the cached field. This test pins both:
+ *
+ * Pure source-text + reflection inspection. No HAL. Sub-millisecond.
+ */
+class HopperStopContractTest {
+ @Test
+ void hopper_has_a_synchronous_void_stop_method() throws NoSuchMethodException {
+ Method m = Hopper.class.getDeclaredMethod("stop");
+ assertThat(m.getReturnType()).isEqualTo(void.class);
+ }
+
+ @Test
+ void no_call_site_outside_default_command_binding_uses_Command_returning_Stop()
+ throws IOException {
+ Pattern badPattern = Pattern.compile("Hopper\\.get\\(\\)\\.Stop\\(\\)");
+ for (Path p : sourceFilesToCheck()) {
+ String src = Files.readString(p);
+ // Strip line comments to avoid false positives in commented-out code.
+ String stripped = src.replaceAll("//[^\\n]*", "");
+ Matcher matcher = badPattern.matcher(stripped);
+ assertThat(matcher.find())
+ .as(
+ "%s must not call Hopper.get().Stop() (Command-returning; result is"
+ + " discarded). Use Hopper.get().stop() (void) instead.",
+ p)
+ .isFalse();
+ }
+ }
+
+ private static java.util.List We do not assert specific physics values — Phoenix6 status signals don't propagate sim
+ * state cleanly in headless JUnit (see ShooterRollerVelocityTest commit notes). What this
+ * test does protect against:
+ *
+ * The aggregation must report the leader motor's signed velocity unmodified. The follower
+ * runs in {@code MotorAlignmentValue.Opposed} mode, so its signed rotor velocity is the negation
+ * of the leader's; averaging cancels to ~0 at speed.
+ *
+ * (Originally written as a Phoenix6 sim integration test, but Phoenix6 status-signal updates
+ * don't propagate reliably in the headless JUnit sim environment. The behavior is small enough
+ * that a pure-logic test gives equivalent confidence at sub-millisecond cost.)
+ */
+class ShooterRollerVelocityTest {
+
+ @Test
+ void at_speed_with_opposed_follower_reports_leader_velocity_not_zero() {
+ // Leader spinning -25 rps; opposed follower's signed rotor velocity is +25 rps.
+ // Buggy averaging: (-25 + 25)/2 = 0. Correct: -25.
+ double agg = Shooter.aggregateRollerVelocity(-25.0, +25.0);
+ assertThat(agg).isEqualTo(-25.0);
+ }
+
+ @Test
+ void at_rest_returns_zero() {
+ assertThat(Shooter.aggregateRollerVelocity(0.0, 0.0)).isEqualTo(0.0);
+ }
+
+ @Test
+ void positive_leader_with_opposed_follower_reports_positive_leader() {
+ // If the shooter were ever commanded forward, leader = +25, follower = -25.
+ assertThat(Shooter.aggregateRollerVelocity(+25.0, -25.0)).isEqualTo(+25.0);
+ }
+
+ @Test
+ void ignores_follower_value_entirely() {
+ // Even if the follower reports a nonsense / desynced value, we trust the leader.
+ assertThat(Shooter.aggregateRollerVelocity(-25.0, 0.0)).isEqualTo(-25.0);
+ assertThat(Shooter.aggregateRollerVelocity(-25.0, +999.0)).isEqualTo(-25.0);
+ assertThat(Shooter.aggregateRollerVelocity(-25.0, -999.0)).isEqualTo(-25.0);
+ }
+
+ @Test
+ void leader_velocity_is_signed() {
+ // Negative target velocities are the normal case; verify sign is preserved.
+ assertThat(Shooter.aggregateRollerVelocity(-50.0, +50.0)).isNegative();
+ assertThat(Shooter.aggregateRollerVelocity(+50.0, -50.0)).isPositive();
+ }
+}
diff --git a/src/test/java/frc/robot/testutil/WpilibTestBase.java b/src/test/java/frc/robot/testutil/WpilibTestBase.java
new file mode 100644
index 0000000..9de1bfb
--- /dev/null
+++ b/src/test/java/frc/robot/testutil/WpilibTestBase.java
@@ -0,0 +1,23 @@
+package frc.robot.testutil;
+
+import edu.wpi.first.hal.HAL;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+
+/**
+ * Base class for tests that need the WPILib HAL initialized (DriverStation, Joystick, Pigeon2,
+ * any subsystem construction). Layer-A pure-logic tests must not extend this — HAL init
+ * adds ~1 second of JVM startup we don't want in the inner loop.
+ */
+public abstract class WpilibTestBase {
+ @BeforeAll
+ static void initHal() {
+ // 500ms timeout, mode 0 (none). Idempotent within a JVM.
+ HAL.initialize(500, 0);
+ }
+
+ @AfterAll
+ static void shutdownHal() {
+ HAL.shutdown();
+ }
+}
diff --git a/src/test/java/frc/robot/util/AllianceFindTest.java b/src/test/java/frc/robot/util/AllianceFindTest.java
new file mode 100644
index 0000000..917779c
--- /dev/null
+++ b/src/test/java/frc/robot/util/AllianceFindTest.java
@@ -0,0 +1,74 @@
+package frc.robot.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import edu.wpi.first.wpilibj.DriverStation;
+import edu.wpi.first.wpilibj.simulation.DriverStationSim;
+import frc.robot.testutil.WpilibTestBase;
+import frc.robot.util.Field.Alliance_Find;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Layer C — drives {@link Alliance_Find#setAlliance()} via {@link DriverStationSim}, asserting
+ * the four hub/pass/climb references switch in lock-step with the simulated alliance.
+ */
+class AllianceFindTest extends WpilibTestBase {
+
+ @BeforeEach
+ void resetAllianceState() {
+ // Reset the static field state between tests so prev_alliance != alliance triggers the
+ // update branch on each setAlliance() call.
+ Alliance_Find.alliance = null;
+ Alliance_Find.prev_alliance = null;
+ DriverStationSim.setDsAttached(true);
+ }
+
+ @AfterEach
+ void clearDsAlliance() {
+ DriverStationSim.setAllianceStationId(
+ edu.wpi.first.hal.AllianceStationID.Unknown);
+ DriverStationSim.notifyNewData();
+ }
+
+ @Test
+ void unknown_alliance_defaults_to_blue_references() {
+ DriverStationSim.setAllianceStationId(edu.wpi.first.hal.AllianceStationID.Unknown);
+ DriverStationSim.notifyNewData();
+ Alliance_Find.setAlliance();
+ assertThat(Alliance_Find.alliance).isEqualTo(DriverStation.Alliance.Blue);
+ // Initial state of class fields is already blue, so they should remain blue references.
+ assertThat(Alliance_Find.hub).isEqualTo(Field.hub_position_blue);
+ assertThat(Alliance_Find.climb_tower).isEqualTo(Field.climb_tower_blue);
+ }
+
+ @Test
+ void red_alliance_switches_all_four_references_to_red() {
+ DriverStationSim.setAllianceStationId(edu.wpi.first.hal.AllianceStationID.Red1);
+ DriverStationSim.notifyNewData();
+ Alliance_Find.setAlliance();
+ assertThat(Alliance_Find.alliance).isEqualTo(DriverStation.Alliance.Red);
+ assertThat(Alliance_Find.hub).isEqualTo(Field.hub_position_red);
+ assertThat(Alliance_Find.Pass_1).isEqualTo(Field.pass_position_red_1);
+ assertThat(Alliance_Find.Pass_2).isEqualTo(Field.pass_position_red_2);
+ assertThat(Alliance_Find.climb_tower).isEqualTo(Field.climb_tower_red);
+ }
+
+ @Test
+ void switching_back_from_red_to_blue_resets_all_four_references() {
+ DriverStationSim.setAllianceStationId(edu.wpi.first.hal.AllianceStationID.Red1);
+ DriverStationSim.notifyNewData();
+ Alliance_Find.setAlliance();
+ assertThat(Alliance_Find.alliance).isEqualTo(DriverStation.Alliance.Red);
+
+ DriverStationSim.setAllianceStationId(edu.wpi.first.hal.AllianceStationID.Blue1);
+ DriverStationSim.notifyNewData();
+ Alliance_Find.setAlliance();
+ assertThat(Alliance_Find.alliance).isEqualTo(DriverStation.Alliance.Blue);
+ assertThat(Alliance_Find.hub).isEqualTo(Field.hub_position_blue);
+ assertThat(Alliance_Find.Pass_1).isEqualTo(Field.pass_position_blue_1);
+ assertThat(Alliance_Find.Pass_2).isEqualTo(Field.pass_position_blue_2);
+ assertThat(Alliance_Find.climb_tower).isEqualTo(Field.climb_tower_blue);
+ }
+}
diff --git a/src/test/java/frc/robot/util/AutoChooserConsistencyTest.java b/src/test/java/frc/robot/util/AutoChooserConsistencyTest.java
new file mode 100644
index 0000000..adeb6b5
--- /dev/null
+++ b/src/test/java/frc/robot/util/AutoChooserConsistencyTest.java
@@ -0,0 +1,83 @@
+package frc.robot.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Layer B — static config validation.
+ *
+ * Parses the source of {@link frc.robot.util.Auto} and asserts two things:
+ *
+ * Source parsing (instead of reflection-on-chooser) keeps this Layer B — no HAL, no
+ * PathPlanner GUI config required.
+ */
+class AutoChooserConsistencyTest {
+ private static final Path AUTO_JAVA = Path.of("src/main/java/frc/robot/util/Auto.java");
+ private static final Path AUTOS_DIR = Path.of("src/main/deploy/pathplanner/autos");
+ private static final Pattern PATH_PLANNER_AUTO_CTOR =
+ Pattern.compile("new\\s+PathPlannerAuto\\s*\\(\\s*\"([^\"]+)\"");
+ // Matches: addOption("LABEL", new PathPlannerAuto("NAME", ...))
+ private static final Pattern ADD_OPTION_WITH_AUTO =
+ Pattern.compile(
+ "addOption\\s*\\(\\s*\"([^\"]+)\"\\s*,\\s*"
+ + "new\\s+PathPlannerAuto\\s*\\(\\s*\"([^\"]+)\"");
+
+ @Test
+ void every_explicit_PathPlannerAuto_name_has_a_matching_auto_file() throws IOException {
+ String source = Files.readString(AUTO_JAVA);
+ Set Uses a small in-test {@code FakeCam} fixture; no HAL, no mocks.
+ */
+class BaseCamTest {
+
+ @Test
+ void no_estimate_means_no_callbacks_and_false_return() {
+ FakeCam cam = new FakeCam(Optional.empty());
+ AtomicInteger added = new AtomicInteger();
+ boolean ret = cam.addVisionEstimate((p, t, v) -> added.incrementAndGet(), r -> true);
+ assertThat(ret).isFalse();
+ assertThat(added.get()).isEqualTo(0);
+ }
+
+ @Test
+ void rejected_estimate_does_not_call_addVisionMeasurement() {
+ FakeCam cam = new FakeCam(Optional.of(makeResult(cam())));
+ AtomicInteger added = new AtomicInteger();
+ boolean ret = cam.addVisionEstimate((p, t, v) -> added.incrementAndGet(), r -> false);
+ assertThat(ret).isFalse();
+ assertThat(added.get()).isEqualTo(0);
+ }
+
+ @Test
+ void accepted_estimate_calls_addVisionMeasurement_exactly_once_with_pose_and_stddev() {
+ AtomicBoolean called = new AtomicBoolean();
+ Pose2d expectedPose = new Pose2d(1, 2, new edu.wpi.first.math.geometry.Rotation2d(0));
+ double expectedTime = 12.5;
+ FakeCam cam = new FakeCam(Optional.of(new FakeCam().new ResultWrapper(
+ expectedPose, expectedTime, 1.0, 1, 0.1).inner));
+ cam.setStdDeviations(0.5, 0.5, 5.0);
+
+ boolean ret = cam.addVisionEstimate(
+ (p, t, v) -> {
+ called.set(true);
+ assertThat(p).isEqualTo(expectedPose);
+ assertThat(t).isEqualTo(expectedTime);
+ assertThat(v.get(0, 0)).isEqualTo(0.5);
+ assertThat(v.get(1, 0)).isEqualTo(0.5);
+ assertThat(v.get(2, 0)).isEqualTo(5.0);
+ },
+ r -> true);
+
+ assertThat(ret).isTrue();
+ assertThat(called.get()).isTrue();
+ }
+
+ @Test
+ void filter_callback_throwing_does_not_propagate_outside() {
+ FakeCam cam = new FakeCam(Optional.of(makeResult(cam())));
+ AtomicInteger added = new AtomicInteger();
+ // Filter throws — addVisionEstimate must catch and still call the measurement callback
+ // (this matches the current BaseCam contract: PLog.fatalException then fall through to
+ // accept and feed the measurement).
+ boolean ret = cam.addVisionEstimate(
+ (p, t, v) -> added.incrementAndGet(),
+ r -> {
+ throw new RuntimeException("filter blew up");
+ });
+ assertThat(ret).isTrue();
+ assertThat(added.get()).isEqualTo(1);
+ }
+
+ private static FakeCam cam() {
+ return new FakeCam();
+ }
+
+ private static AprilTagResult makeResult(FakeCam outer) {
+ return outer.new ResultWrapper(
+ new Pose2d(0, 0, new edu.wpi.first.math.geometry.Rotation2d(0)),
+ 0.0,
+ 2.0,
+ 1,
+ 0.1)
+ .inner;
+ }
+
+ /** Concrete subclass that lets us hand it a precomputed Optional Walks every public static Translation2d / Pose2d declared on {@link frc.robot.util.Field}
+ * and asserts the coordinates are on the field. Catches typos like {@code 5201} (should have been
+ * {@code 5.201}).
+ */
+class FieldBoundsTest {
+ private static final double FIELD_LENGTH_M =
+ frc.robot.util.Field.LENGTH.in(Meters);
+ private static final double FIELD_WIDTH_M =
+ frc.robot.util.Field.WIDTH.in(Meters);
+
+ @TestFactory
+ List Catches regressions if someone changes the bracket format or drops one of the three fields.
+ */
+class PLogTest {
+ private PrintStream originalOut;
+ private ByteArrayOutputStream captured;
+
+ @BeforeEach
+ void captureStdout() {
+ originalOut = System.out;
+ captured = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(captured));
+ }
+
+ @AfterEach
+ void restoreStdout() {
+ System.setOut(originalOut);
+ }
+
+ @Test
+ void debug_writes_severity_category_message_in_canonical_format() {
+ PLog.debug("Cat", "msg");
+ assertThat(captured.toString()).contains("[Debug] [Cat] msg");
+ }
+
+ @Test
+ void info_writes_severity_category_message() {
+ PLog.info("Cat", "hello");
+ assertThat(captured.toString()).contains("[Info] [Cat] hello");
+ }
+
+ @Test
+ void unusual_writes_severity_category_message() {
+ PLog.unusual("Cat", "huh");
+ assertThat(captured.toString()).contains("[Unusual] [Cat] huh");
+ }
+
+ @Test
+ void fatalException_includes_exception_class_simple_name_and_stack_trace() {
+ Exception e = new IllegalStateException("boom");
+ PLog.fatalException("Cat", "user said something", e);
+ String out = captured.toString();
+ assertThat(out).contains("[Fatal]");
+ assertThat(out).contains("[Cat]");
+ assertThat(out).contains("user said something");
+ assertThat(out).contains("IllegalStateException");
+ assertThat(out).contains("boom");
+ // At least one stacktrace element with 4-space indent.
+ assertThat(out).contains(" ");
+ }
+
+ @Test
+ void log_format_is_stable_across_severities() {
+ PLog.debug("X", "a");
+ PLog.info("Y", "b");
+ PLog.unusual("Z", "c");
+ String out = captured.toString();
+ assertThat(out)
+ .containsSubsequence(
+ "[Debug] [X] a", "[Info] [Y] b", "[Unusual] [Z] c");
+ }
+}
diff --git a/src/test/java/frc/robot/util/ShooterControlPolicyTest.java b/src/test/java/frc/robot/util/ShooterControlPolicyTest.java
new file mode 100644
index 0000000..9e04fe9
--- /dev/null
+++ b/src/test/java/frc/robot/util/ShooterControlPolicyTest.java
@@ -0,0 +1,124 @@
+package frc.robot.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import edu.wpi.first.math.interpolation.InterpolatingDoubleTreeMap;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Layer A — pure-logic tests of the shooter "should we fire the indexer?" decision, extracted
+ * from {@link frc.robot.commands.ShootCommand} so it can be exhaustively tested with no HAL and
+ * no mocks.
+ *
+ * The contract:
+ * No HAL, no mocks. Sub-millisecond per test.
+ */
+class VisionFilterTest {
+
+ @Test
+ void rejects_when_dist_to_tag_above_3_5_m() {
+ var d = VisionFilter.evaluate(/*dist*/ 3.6, /*amb*/ 0.1, /*omega*/ 0.0);
+ assertThat(d.accept()).isFalse();
+ }
+
+ @Test
+ void rejects_when_ambiguity_above_0_7() {
+ var d = VisionFilter.evaluate(2.0, 0.71, 0.0);
+ assertThat(d.accept()).isFalse();
+ }
+
+ @Test
+ void rejects_when_rotating_too_fast() {
+ var d = VisionFilter.evaluate(2.0, 0.1, 1.6);
+ assertThat(d.accept()).isFalse();
+ // Symmetric: a fast negative rotation is also rejected.
+ var d2 = VisionFilter.evaluate(2.0, 0.1, -1.6);
+ assertThat(d2.accept()).isFalse();
+ }
+
+ @Test
+ void accepts_close_target_within_thresholds() {
+ var d = VisionFilter.evaluate(2.0, 0.2, 0.5);
+ assertThat(d.accept()).isTrue();
+ }
+
+ @Test
+ void uses_fixed_stddev_when_dist_under_half_meter() {
+ var d = VisionFilter.evaluate(0.4, 0.3, 0.0);
+ assertThat(d.accept()).isTrue();
+ assertVec(d.stdDevs(), 0.3, 0.3, 50.0);
+ }
+
+ @Test
+ void scales_stddev_quadratically_with_distance_outside_half_meter() {
+ double dist = 2.0;
+ double amb = 0.5;
+ double expected = amb * dist * dist * 3.0; // == 0.5 * 4 * 3 = 6.0
+ var d = VisionFilter.evaluate(dist, amb, 0.0);
+ assertThat(d.accept()).isTrue();
+ assertVec(d.stdDevs(), expected, expected, expected);
+ }
+
+ @Test
+ void boundary_dist_3_5_exact_is_accepted() {
+ // The original code uses strict inequality (> 3.5 rejects), so exactly 3.5 is in.
+ var d = VisionFilter.evaluate(3.5, 0.1, 0.0);
+ assertThat(d.accept()).isTrue();
+ }
+
+ @Test
+ void boundary_ambiguity_0_7_exact_is_accepted() {
+ var d = VisionFilter.evaluate(2.0, 0.7, 0.0);
+ assertThat(d.accept()).isTrue();
+ }
+
+ @Test
+ void boundary_omega_1_5_exact_is_accepted() {
+ var d = VisionFilter.evaluate(2.0, 0.1, 1.5);
+ assertThat(d.accept()).isTrue();
+ }
+
+ @Test
+ void boundary_dist_0_5_exact_uses_quadratic_branch() {
+ // Code path is `dist < 0.5 -> fixed`, so 0.5 falls through to quadratic.
+ double dist = 0.5;
+ double amb = 0.4;
+ double expected = amb * dist * dist * 3.0;
+ var d = VisionFilter.evaluate(dist, amb, 0.0);
+ assertThat(d.accept()).isTrue();
+ assertVec(d.stdDevs(), expected, expected, expected);
+ }
+
+ private static void assertVec(Matrix
+ *
+ *
+ *
+ * if (distToTag > 3.5) return false;
+ * if (ambiguity > 0.7) return false;
+ * if (omegaRadPerSec > 1.5) return false; // bug: not abs()-wrapped
+ * if (distToTag < 0.5) setStdDevs(0.3, 0.3, 50)
+ * else setStdDevs(amb * dist^2 * 3, ...)
+ *
+ *
+ *
+ *
+ */
+class AlignToClimbTest {
+ @Test
+ void swerve_does_not_cache_climb_target_pose_at_construction() {
+ for (Field f : Swerve.class.getDeclaredFields()) {
+ int mods = f.getModifiers();
+ if (Modifier.isStatic(mods)) continue;
+ String type = f.getType().getSimpleName();
+ if (!"Pose2d".equals(type)) continue;
+ String name = f.getName().toLowerCase();
+ assertThat(name)
+ .as(
+ "Swerve.%s is a cached Pose2d — alignToClimb()'s climb_tower lookup"
+ + " must be deferred to schedule-time, not stored in a field"
+ + " at construction (alliance isn't known yet then). See"
+ + " AlignToClimbTest.",
+ f.getName())
+ .doesNotContain("target")
+ .doesNotContain("climb")
+ .doesNotContain("tower");
+ }
+ }
+
+ @Test
+ void alignToClimb_uses_Commands_defer_for_lazy_alliance_lookup() throws IOException {
+ String src = Files.readString(Path.of("src/main/java/frc/robot/subsystem/Swerve.java"));
+ // Strip comments to reduce false positives.
+ String stripped = src.replaceAll("//[^\\n]*", "");
+ // alignToClimb should call Commands.defer (which evaluates its supplier at schedule
+ // time) instead of returning a pre-built AutoBuilder.pathfindToPose(...) command.
+ Pattern alignToClimbBody =
+ Pattern.compile(
+ "public\\s+Command\\s+alignToClimb\\s*\\(\\s*\\)\\s*\\{([^}]+)\\}",
+ Pattern.DOTALL);
+ var m = alignToClimbBody.matcher(stripped);
+ assertThat(m.find()).as("expected to find alignToClimb() in Swerve.java").isTrue();
+ String body = m.group(1);
+ assertThat(body)
+ .as(
+ "alignToClimb() body should call Commands.defer(...) so the alliance"
+ + " lookup happens at schedule-time, not at Swerve construction"
+ + " time. See AlignToClimbTest.")
+ .contains("Commands.defer(");
+ }
+}
diff --git a/src/test/java/frc/robot/subsystem/HopperStopContractTest.java b/src/test/java/frc/robot/subsystem/HopperStopContractTest.java
new file mode 100644
index 0000000..ea8735f
--- /dev/null
+++ b/src/test/java/frc/robot/subsystem/HopperStopContractTest.java
@@ -0,0 +1,66 @@
+package frc.robot.subsystem;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Layer A + Layer B — locks down the {@link Hopper#stop()} contract and its callers.
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+class IntakeSimTest extends WpilibTestBase {
+
+ @Test
+ void simulationPeriodic_runs_for_several_ticks_without_throwing() throws Exception {
+ Intake intake = Intake.get();
+ for (int i = 0; i < 20; i++) {
+ intake.simulationPeriodic();
+ intake.periodic();
+ SimHooks.stepTiming(0.020);
+ }
+ double pivotPos = privateDouble(intake, "cachedIntakePivotPosition");
+ double rollerVel = privateDouble(intake, "cachedRollerVelocity");
+ assertThat(pivotPos).as("cached pivot position should be a finite number").isFinite();
+ assertThat(rollerVel).as("cached roller velocity should be a finite number").isFinite();
+ }
+
+ private static double privateDouble(Object obj, String name) throws Exception {
+ Field f = obj.getClass().getDeclaredField(name);
+ f.setAccessible(true);
+ return f.getDouble(obj);
+ }
+}
diff --git a/src/test/java/frc/robot/subsystem/ShooterRollerVelocityTest.java b/src/test/java/frc/robot/subsystem/ShooterRollerVelocityTest.java
new file mode 100644
index 0000000..5b76276
--- /dev/null
+++ b/src/test/java/frc/robot/subsystem/ShooterRollerVelocityTest.java
@@ -0,0 +1,53 @@
+package frc.robot.subsystem;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Layer A — pure-logic test for {@link Shooter#aggregateRollerVelocity(double, double)}.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+class ShooterControlPolicyTest {
+ // Tables roughly matching BotConstants.Shooter.* but small and self-contained.
+ private static final InterpolatingDoubleTreeMap VEL = treeMap(1.5, -25.0, 3.0, -30.0);
+ private static final InterpolatingDoubleTreeMap IDX = treeMap(1.5, -20.0, 3.0, -28.0);
+ private static final double ENTER = 0.95;
+ private static final double EXIT = 1.5;
+
+ @Test
+ void flywheel_target_is_interpolated_from_distance() {
+ var d = ShooterControlPolicy.compute(2.25, 0.0, VEL, IDX, ENTER, EXIT, false);
+ assertThat(d.flywheelTarget()).isCloseTo(-27.5, within(1e-9));
+ assertThat(d.indexerSpeed()).isCloseTo(-24.0, within(1e-9));
+ }
+
+ @Test
+ void does_not_fire_when_far_from_target_and_not_currently_firing() {
+ var d = ShooterControlPolicy.compute(1.5, 0.0, VEL, IDX, ENTER, EXIT, false);
+ // target is -25, measured is 0, |err| = 25 -> not within enter band
+ assertThat(d.fireIndexer()).isFalse();
+ }
+
+ @Test
+ void fires_once_within_enter_band() {
+ // target is -25, measured -24.5 -> |err| = 0.5 < 0.95
+ var d = ShooterControlPolicy.compute(1.5, -24.5, VEL, IDX, ENTER, EXIT, false);
+ assertThat(d.fireIndexer()).isTrue();
+ }
+
+ @Test
+ void keeps_firing_while_inside_exit_band() {
+ // target -25, measured -23.7 -> |err| = 1.3, between enter (0.95) and exit (1.5)
+ var d = ShooterControlPolicy.compute(1.5, -23.7, VEL, IDX, ENTER, EXIT, /*wasFiring*/ true);
+ assertThat(d.fireIndexer()).isTrue();
+ }
+
+ @Test
+ void stops_firing_when_drifting_past_exit_band() {
+ // target -25, measured -22 -> |err| = 3 > 1.5
+ var d = ShooterControlPolicy.compute(1.5, -22.0, VEL, IDX, ENTER, EXIT, /*wasFiring*/ true);
+ assertThat(d.fireIndexer()).isFalse();
+ }
+
+ @Test
+ void exit_band_is_wider_than_enter_band_so_short_dips_do_not_drop_indexer() {
+ // Enter -> firing
+ var d1 = ShooterControlPolicy.compute(1.5, -24.5, VEL, IDX, ENTER, EXIT, false);
+ assertThat(d1.fireIndexer()).isTrue();
+
+ // Small dip: |err|=1.2, outside enter (0.95) but inside exit (1.5) -> keep firing
+ var d2 = ShooterControlPolicy.compute(1.5, -23.8, VEL, IDX, ENTER, EXIT, true);
+ assertThat(d2.fireIndexer()).isTrue();
+ }
+
+ @Test
+ void enter_just_inside_band_fires() {
+ // target -25, measured -24.1 -> err = 0.9 (clearly inside enter band of 0.95)
+ var d = ShooterControlPolicy.compute(1.5, -24.1, VEL, IDX, ENTER, EXIT, false);
+ assertThat(d.fireIndexer()).isTrue();
+ }
+
+ @Test
+ void enter_just_outside_band_does_not_fire() {
+ // target -25, measured -24 -> err = 1.0 (just outside enter band)
+ var d = ShooterControlPolicy.compute(1.5, -24.0, VEL, IDX, ENTER, EXIT, false);
+ assertThat(d.fireIndexer()).isFalse();
+ }
+
+ @Test
+ void exit_just_inside_band_keeps_firing() {
+ // target -25, measured -23.6 -> err = 1.4 (clearly inside exit band of 1.5)
+ var d = ShooterControlPolicy.compute(1.5, -23.6, VEL, IDX, ENTER, EXIT, true);
+ assertThat(d.fireIndexer()).isTrue();
+ }
+
+ @Test
+ void exit_just_outside_band_stops_firing() {
+ // target -25, measured -23.4 -> err = 1.6 (just outside exit band)
+ var d = ShooterControlPolicy.compute(1.5, -23.4, VEL, IDX, ENTER, EXIT, true);
+ assertThat(d.fireIndexer()).isFalse();
+ }
+
+ @Test
+ void distance_below_table_min_clamps_to_lowest_velocity() {
+ var d = ShooterControlPolicy.compute(0.5, 0.0, VEL, IDX, ENTER, EXIT, false);
+ assertThat(d.flywheelTarget()).isEqualTo(-25.0);
+ }
+
+ @Test
+ void distance_above_table_max_clamps_to_highest_velocity() {
+ var d = ShooterControlPolicy.compute(99.0, 0.0, VEL, IDX, ENTER, EXIT, false);
+ assertThat(d.flywheelTarget()).isEqualTo(-30.0);
+ }
+
+ private static InterpolatingDoubleTreeMap treeMap(double k1, double v1, double k2, double v2) {
+ var m = new InterpolatingDoubleTreeMap();
+ m.put(k1, v1);
+ m.put(k2, v2);
+ return m;
+ }
+
+ private static org.assertj.core.data.Offset