diff --git a/build.gradle b/build.gradle index adff7404..dcaba89f 100644 --- a/build.gradle +++ b/build.gradle @@ -99,11 +99,16 @@ dependencies { test { useJUnitPlatform() systemProperty 'junit.jupiter.extensions.autodetection.enabled', 'true' + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } } // Simulation configuration (e.g. environment variables). -wpi.sim.addGui().defaultEnabled = true -wpi.sim.addDriverstation() +def isCI = System.getenv("CI") != null || project.hasProperty("headless") +wpi.sim.addGui().defaultEnabled = !isCI +wpi.sim.addDriverstation().defaultEnabled = !isCI // Setting up my Jar File. In this case, adding all libraries into the main jar ('fat jar') // in order to make them all available at runtime. Also adding the manifest so WPILib diff --git a/docs/tools/simulation.md b/docs/tools/simulation.md index 75cf6b9a..6748ce07 100644 --- a/docs/tools/simulation.md +++ b/docs/tools/simulation.md @@ -47,6 +47,25 @@ That view is especially useful for autos. Select the auto in Elastic or Glass, e AdvantageScope can also replay the same data from a `.wpilog` after the run. That makes the workflow: test the auto in sim, use 3D Field live while it runs, then open the saved log if you need to scrub frame-by-frame through the exact moment the path or pose estimate went sideways. +## Automated Simulation Testing in CI/CD + +In addition to interactive debugging in Glass and AdvantageScope, the repository runs automated JUnit 5 simulation integration tests as part of `./gradlew build` and GitHub Actions CI. + +The simulation test suite lives under [`src/test/java/frc/robot/sim/`](../../src/test/java/frc/robot/sim/) and is built around [`SimTestBase`](../../src/test/java/frc/robot/sim/SimTestBase.java), which handles: + +* Initializing WPILib's Hardware Abstraction Layer (HAL) in simulation mode (`HAL.initialize(50, 0)`). +* Managing `DriverStationSim` state transitions (teleop and autonomous modes). +* Stepping simulation timing deterministically via `SimHooks.stepTiming(...)` and executing the `CommandScheduler`. +* Resetting scheduler, notifier, and DriverStation state between test runs so tests remain isolated. + +In CI (`CI=true`) or when `-Pheadless=true` is passed to Gradle, `wpi.sim.addGui().defaultEnabled` and `wpi.sim.addDriverstation().defaultEnabled` are automatically set to `false`, allowing simulation tests to run in headless Ubuntu Linux containers without requiring X11 display servers. + +You can run the full verification suite locally using: + +```bash +scripts/verify.sh +``` + ## What Simulation Catches (and Doesn't) It catches: diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 00000000..dfffbd37 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -e + +# Change to repo root +cd "$(dirname "$0")/.." + +if [ "$1" = "--fix" ]; then + echo "=== Running Spotless Apply ===" + ./gradlew spotlessApply +else + echo "=== Running Spotless Check ===" + ./gradlew spotlessCheck +fi + +echo "=== Running SpotBugs Static Analysis ===" +./gradlew spotbugsMain + +echo "=== Running Compile and Tests (Unit & Sim Tests) ===" +./gradlew test -Pheadless=true + +echo "=== Verification SUCCESSFUL ===" diff --git a/src/main/java/frc/robot/auton/Auton.java b/src/main/java/frc/robot/auton/Auton.java index 34469a52..eb5c1b73 100644 --- a/src/main/java/frc/robot/auton/Auton.java +++ b/src/main/java/frc/robot/auton/Auton.java @@ -22,6 +22,7 @@ import frc.spectrumLib.framework.SpectrumState; import frc.spectrumLib.telemetry.Telemetry; import java.io.IOException; +import lombok.Getter; import org.json.simple.parser.ParseException; public class Auton { @@ -35,7 +36,7 @@ public class Auton { public static final EventTrigger autonUnjam = new EventTrigger("unjam"); public static final EventTrigger autonPoseUpdate = new EventTrigger("poseUpdate"); - private final SendableChooser pathChooser = new SendableChooser<>(); + @Getter private final SendableChooser pathChooser = new SendableChooser<>(); private boolean autoMessagePrinted = true; private double autonStart = 0; diff --git a/src/main/java/frc/robot/subsystems/swerve/Swerve.java b/src/main/java/frc/robot/subsystems/swerve/Swerve.java index 8ab3b7ec..cacb496c 100644 --- a/src/main/java/frc/robot/subsystems/swerve/Swerve.java +++ b/src/main/java/frc/robot/subsystems/swerve/Swerve.java @@ -56,7 +56,8 @@ * Class that extends the Phoenix SwerveDrivetrain class and implements subsystem so it can be used * in command-based projects easily. */ -public class Swerve extends SwerveDrivetrain implements Subsystem { +public class Swerve extends SwerveDrivetrain + implements Subsystem, AutoCloseable { // ── State machine ────────────────────────────────────────────────────────────────── public enum WantedState { @@ -747,7 +748,10 @@ private void configurePathPlanner() { @Getter private RobotBumpSim robotBumpSim = null; @Getter private Pose3d simRobotPose3d = Pose3d.kZero; - /** Starts the sim thread. */ + /** + * Starts the simulated drivetrain and bump simulation, then schedules periodic simulation + * updates. + */ @SuppressWarnings("unchecked") private void startSimThread() { mapleSimSwerveDrivetrain = @@ -772,4 +776,13 @@ private void startSimThread() { simNotifier = new Notifier(mapleSimSwerveDrivetrain::update); simNotifier.startPeriodic(config.getSimLoopPeriod()); } + + /** Stops the simulation thread and cleans up resources. */ + @Override + public void close() { + if (simNotifier != null) { + simNotifier.close(); + simNotifier = null; + } + } } diff --git a/src/main/java/frc/spectrumLib/sim/SimLoop.java b/src/main/java/frc/spectrumLib/sim/SimLoop.java index aeca7fd8..e0e8fb07 100644 --- a/src/main/java/frc/spectrumLib/sim/SimLoop.java +++ b/src/main/java/frc/spectrumLib/sim/SimLoop.java @@ -42,7 +42,10 @@ public static synchronized void register(DoubleConsumer step) { notifier = started; } } - /** Tick. */ + /** + * Advances the simulation loop and notifies each registered callback of the computed elapsed + * time. + */ private static void tick() { double now = Utils.getCurrentTimeSeconds(); double dt = now - lastTime; @@ -51,4 +54,16 @@ private static void tick() { step.accept(dt); } } + + /** + * Clears registered step callbacks and stops the notifier thread. Intended for unit/simulation + * tests. + */ + public static synchronized void reset() { + steps.clear(); + if (notifier != null) { + notifier.close(); + notifier = null; + } + } } diff --git a/src/test/java/frc/robot/sim/AutonSimTest.java b/src/test/java/frc/robot/sim/AutonSimTest.java new file mode 100644 index 00000000..44f1480e --- /dev/null +++ b/src/test/java/frc/robot/sim/AutonSimTest.java @@ -0,0 +1,45 @@ +package frc.robot.sim; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import edu.wpi.first.wpilibj2.command.CommandScheduler; +import frc.robot.auton.Auton; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Automated simulation tests for the autonomous routine chooser and execution. */ +public class AutonSimTest extends SimTestBase { + + @Test + @DisplayName("Auton initializes the SendableChooser and default option without exceptions") + void testAutonChooserInitialization() { + assertDoesNotThrow( + () -> { + RobotStack stack = createRobotStack(); + try (stack.swerve) { + Auton auton = new Auton(stack.superStructure); + assertNotNull(auton.getPathChooser()); + assertNotNull(auton.getPathChooser().getSelected()); + } + }); + } + + @Test + @DisplayName("Auton init schedules the selected auto and steps simulation time cleanly") + void testAutonInitAndStep() { + assertDoesNotThrow( + () -> { + RobotStack stack = createRobotStack(); + try (stack.swerve) { + Auton auton = new Auton(stack.superStructure); + CommandScheduler.getInstance().registerSubsystem(stack.superStructure); + + enableAutonomousSim(); + auton.init(); + + stepSim(0.020, 0.50); + } + }); + } +} diff --git a/src/test/java/frc/robot/sim/MechanismSimTest.java b/src/test/java/frc/robot/sim/MechanismSimTest.java new file mode 100644 index 00000000..5ad3777e --- /dev/null +++ b/src/test/java/frc/robot/sim/MechanismSimTest.java @@ -0,0 +1,68 @@ +package frc.robot.sim; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import edu.wpi.first.wpilibj2.command.CommandScheduler; +import frc.robot.subsystems.hood.Hood; +import frc.robot.subsystems.intakeExtension.IntakeExtension; +import frc.robot.subsystems.turret.Turret; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Automated simulation tests for individual mechanism subsystems. */ +public class MechanismSimTest extends SimTestBase { + + @Test + @DisplayName("Hood subsystem initializes and runs simulation steps without exceptions") + void testHoodSimPeriodic() { + assertDoesNotThrow( + () -> { + Hood hood = new Hood(new Hood.HoodConfig()); + CommandScheduler.getInstance().registerSubsystem(hood); + + enableTeleopSim(); + stepSim(0.020, 0.50); + + assertNotNull(hood.getSim()); + }); + } + + @Test + @DisplayName("Turret subsystem initializes and runs simulation steps without exceptions") + void testTurretSimPeriodic() { + assertDoesNotThrow( + () -> { + Turret turret = new Turret(new Turret.TurretConfig()); + CommandScheduler.getInstance().registerSubsystem(turret); + + enableTeleopSim(); + stepSim(0.020, 0.50); + + assertNotNull(turret.getSim()); + }); + } + + @Test + @DisplayName( + "IntakeExtension subsystem initializes and runs simulation steps without exceptions") + void testIntakeExtensionSimPeriodic() { + assertDoesNotThrow( + () -> { + IntakeExtension.Left.LeftConfig leftConfig = + new IntakeExtension.Left.LeftConfig(); + IntakeExtension.Right.RightConfig rightConfig = + new IntakeExtension.Right.RightConfig(leftConfig); + IntakeExtension intakeExtension = + new IntakeExtension( + new IntakeExtension.IntakeExtensionConfig( + leftConfig, rightConfig)); + CommandScheduler.getInstance().registerSubsystem(intakeExtension); + + enableTeleopSim(); + stepSim(0.020, 0.50); + + assertNotNull(intakeExtension.getSim()); + }); + } +} diff --git a/src/test/java/frc/robot/sim/RobotSimTest.java b/src/test/java/frc/robot/sim/RobotSimTest.java new file mode 100644 index 00000000..3f7a5c97 --- /dev/null +++ b/src/test/java/frc/robot/sim/RobotSimTest.java @@ -0,0 +1,36 @@ +package frc.robot.sim; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import edu.wpi.first.wpilibj2.command.CommandScheduler; +import frc.robot.RobotSim; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Automated simulation tests for the RobotSim visualizer and fuel physics simulation. */ +public class RobotSimTest extends SimTestBase { + + @Test + @DisplayName("RobotSim initializes visualizer and fuel physics and updates cleanly") + void testRobotSimCreationAndPeriodic() { + assertDoesNotThrow( + () -> { + RobotStack stack = createRobotStack(); + try (stack.swerve) { + RobotSim robotSim = new RobotSim(stack.superStructure); + CommandScheduler.getInstance().registerSubsystem(stack.superStructure); + + assertNotNull(robotSim.getBallSim()); + assertNotNull(RobotSim.leftView); + assertNotNull(RobotSim.topView); + + enableTeleopSim(); + stepSim(0.020, 0.50); + + robotSim.updateArticulatedMechanisms(); + robotSim.getBallSim().tick(); + } + }); + } +} diff --git a/src/test/java/frc/robot/sim/SimTestBase.java b/src/test/java/frc/robot/sim/SimTestBase.java new file mode 100644 index 00000000..b67542ba --- /dev/null +++ b/src/test/java/frc/robot/sim/SimTestBase.java @@ -0,0 +1,184 @@ +package frc.robot.sim; + +import edu.wpi.first.hal.HAL; +import edu.wpi.first.wpilibj.simulation.DriverStationSim; +import edu.wpi.first.wpilibj.simulation.SimHooks; +import edu.wpi.first.wpilibj2.command.CommandScheduler; +import frc.robot.Robot; +import frc.robot.configs.OM2026; +import frc.robot.subsystems.SuperStructure; +import frc.robot.subsystems.dyeRotor.DyeRotor; +import frc.robot.subsystems.fuelIntake.FuelIntake; +import frc.robot.subsystems.hood.Hood; +import frc.robot.subsystems.intakeExtension.IntakeExtension; +import frc.robot.subsystems.launcher.Launcher; +import frc.robot.subsystems.launcher.LauncherTower; +import frc.robot.subsystems.swerve.Swerve; +import frc.robot.subsystems.turret.Turret; +import frc.spectrumLib.sim.SimLoop; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; + +/** Base test fixture for WPILib simulation integration tests. */ +public abstract class SimTestBase { + + /** + * Helper class to hold the full robot stack components for simulation tests. Resources are + * owned by the caller and must be closed appropriately. + */ + public static class RobotStack { + public final Robot.Config config; + public final Swerve swerve; + public final FuelIntake fuelIntake; + public final IntakeExtension intakeExtension; + public final DyeRotor dyeRotor; + public final Launcher launcher; + public final LauncherTower launcherTower; + public final Turret turret; + public final Hood hood; + public final SuperStructure superStructure; + + /** + * Creates a robot stack containing the configuration and robot subsystems. + * + * @param config the robot configuration + * @param swerve the swerve drivetrain + * @param fuelIntake the fuel intake + * @param intakeExtension the intake extension + * @param dyeRotor the dye rotor + * @param launcher the launcher + * @param launcherTower the launcher tower + * @param turret the turret + * @param hood the hood + * @param superStructure the superstructure + */ + public RobotStack( + Robot.Config config, + Swerve swerve, + FuelIntake fuelIntake, + IntakeExtension intakeExtension, + DyeRotor dyeRotor, + Launcher launcher, + LauncherTower launcherTower, + Turret turret, + Hood hood, + SuperStructure superStructure) { + this.config = config; + this.swerve = swerve; + this.fuelIntake = fuelIntake; + this.intakeExtension = intakeExtension; + this.dyeRotor = dyeRotor; + this.launcher = launcher; + this.launcherTower = launcherTower; + this.turret = turret; + this.hood = hood; + this.superStructure = superStructure; + } + } + + /** + * Creates a full robot stack with OM2026 configuration for simulation testing. The caller is + * responsible for closing the Swerve resource (use try-with-resources). + * + * @return a RobotStack containing all configured subsystems and SuperStructure + */ + protected static RobotStack createRobotStack() { + Robot.Config config = new OM2026(); + + Swerve swerve = new Swerve(config.swerve); + try { + FuelIntake fuelIntake = new FuelIntake(config.fuelIntake); + IntakeExtension intakeExtension = new IntakeExtension(config.intakeExtension); + DyeRotor dyeRotor = new DyeRotor(config.dyeRotor); + Launcher launcher = new Launcher(config.launcher); + LauncherTower launcherTower = new LauncherTower(config.launcherTower); + Turret turret = new Turret(config.turret); + Hood hood = new Hood(config.hood); + + SuperStructure superStructure = + new SuperStructure( + swerve, + fuelIntake, + intakeExtension, + dyeRotor, + launcher, + launcherTower, + turret, + hood); + + return new RobotStack( + config, + swerve, + fuelIntake, + intakeExtension, + dyeRotor, + launcher, + launcherTower, + turret, + hood, + superStructure); + } catch (Exception e) { + swerve.close(); + throw e; + } + } + + /** Initializes WPILib HAL for simulation. */ + @BeforeAll + static void initHAL() { + assert HAL.initialize(50, 0) : "Failed to initialize WPILib HAL for simulation"; + } + + /** Prepares clean simulation state before each test. */ + @BeforeEach + void setupSim() { + SimHooks.pauseTiming(); + CommandScheduler.getInstance().cancelAll(); + CommandScheduler.getInstance().unregisterAllSubsystems(); + SimLoop.reset(); + + DriverStationSim.resetData(); + DriverStationSim.setDsAttached(true); + DriverStationSim.setEnabled(false); + DriverStationSim.setAutonomous(false); + DriverStationSim.notifyNewData(); + } + + /** Cleans up simulation state after each test. */ + @AfterEach + void teardownSim() { + SimHooks.resumeTiming(); + CommandScheduler.getInstance().cancelAll(); + CommandScheduler.getInstance().unregisterAllSubsystems(); + SimLoop.reset(); + } + + /** + * Steps simulation time forward and runs the CommandScheduler. + * + * @param dtSeconds time step per loop (typically 0.020 for 50 Hz) + * @param durationSeconds total simulation time to advance + */ + protected void stepSim(double dtSeconds, double durationSeconds) { + int steps = (int) Math.round(durationSeconds / dtSeconds); + for (int i = 0; i < steps; i++) { + SimHooks.stepTiming(dtSeconds); + CommandScheduler.getInstance().run(); + } + } + + /** Sets the simulated DriverStation to Autonomous Enabled. */ + protected void enableAutonomousSim() { + DriverStationSim.setAutonomous(true); + DriverStationSim.setEnabled(true); + DriverStationSim.notifyNewData(); + } + + /** Sets the simulated DriverStation to Teleop Enabled. */ + protected void enableTeleopSim() { + DriverStationSim.setAutonomous(false); + DriverStationSim.setEnabled(true); + DriverStationSim.notifyNewData(); + } +} diff --git a/src/test/java/frc/robot/sim/SuperStructureSimTest.java b/src/test/java/frc/robot/sim/SuperStructureSimTest.java new file mode 100644 index 00000000..bde76224 --- /dev/null +++ b/src/test/java/frc/robot/sim/SuperStructureSimTest.java @@ -0,0 +1,39 @@ +package frc.robot.sim; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import edu.wpi.first.wpilibj2.command.CommandScheduler; +import frc.robot.subsystems.SuperStructure; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Automated simulation tests for the SuperStructure coordination subsystem. */ +public class SuperStructureSimTest extends SimTestBase { + + @Test + @DisplayName( + "SuperStructure initializes all subsystems and steps simulation without exceptions") + void testSuperStructureSimPeriodic() { + assertDoesNotThrow( + () -> { + RobotStack stack = createRobotStack(); + try (stack.swerve) { + CommandScheduler.getInstance().registerSubsystem(stack.superStructure); + + enableTeleopSim(); + + CommandScheduler.getInstance() + .schedule( + stack.superStructure.setStateCommand( + SuperStructure.WantedSuperState.INTAKE_FUEL)); + + stepSim(0.020, 0.50); + + assertEquals( + SuperStructure.CurrentSuperState.INTAKE_FUEL, + stack.superStructure.getCurrentSuperState()); + } + }); + } +}