feat: Add template system to weevil new command

Implements template-based project creation allowing teams to start with
professional example code instead of empty projects.

Features:
- Two templates: 'basic' (minimal) and 'testing' (45-test showcase)
- Template variable substitution ({{PROJECT_NAME}}, etc.)
- Template validation with helpful error messages
- `weevil new --list-templates` command
- Template files embedded in binary at compile time

Technical details:
- Templates stored in templates/basic/ and templates/testing/
- Files ending in .template have variables replaced
- Uses include_dir! macro to embed templates in binary
- Returns file count for user feedback

Testing template includes:
- 3 complete subsystems (MotorCycler, WallApproach, TurnController)
- Hardware abstraction layer with mock implementations
- 45 comprehensive tests (unit, integration, system)
- Professional documentation (DESIGN_AND_TEST_PLAN.md, etc.)

Usage:
  weevil new my-robot                    # basic template
  weevil new my-robot --template testing # testing showcase
  weevil new --list-templates            # show available templates

This enables FTC teams to learn from working code and best practices
rather than starting from scratch.
This commit is contained in:
Eric Ratliff
2026-02-02 19:14:50 -06:00
parent 0431425f38
commit 60679e097f
42 changed files with 5521 additions and 82 deletions

View File

@@ -0,0 +1,19 @@
package robot.hardware;
/**
* Interface for distance sensors (ultrasonic, time-of-flight, etc.).
* This abstraction allows testing distance-based behaviors without physical sensors.
*/
public interface DistanceSensor {
/**
* Get the current distance reading in centimeters.
* @return distance in cm, or -1 if sensor error
*/
double getDistanceCm();
/**
* Check if the sensor has valid data.
* @return true if sensor is working and has valid reading
*/
boolean isValid();
}

View File

@@ -0,0 +1,27 @@
package robot.hardware;
import com.qualcomm.robotcore.hardware.DistanceSensor;
import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit;
/**
* FTC implementation of DistanceSensor interface.
* This file will be EXCLUDED from test compilation.
*/
public class FtcDistanceSensor implements robot.hardware.DistanceSensor {
private final DistanceSensor sensor;
public FtcDistanceSensor(DistanceSensor sensor) {
this.sensor = sensor;
}
@Override
public double getDistanceCm() {
return sensor.getDistance(DistanceUnit.CM);
}
@Override
public boolean isValid() {
double dist = getDistanceCm();
return dist > 0 && dist < 8190; // Valid range for REV sensors
}
}

View File

@@ -0,0 +1,39 @@
package robot.hardware;
import com.qualcomm.robotcore.hardware.IMU;
import org.firstinspires.ftc.robotcore.external.navigation.AngleUnit;
import org.firstinspires.ftc.robotcore.external.navigation.YawPitchRollAngles;
/**
* FTC implementation of GyroSensor using REV Hub IMU.
* This file will be EXCLUDED from test compilation.
*/
public class FtcGyroSensor implements GyroSensor {
private final IMU imu;
public FtcGyroSensor(IMU imu) {
this.imu = imu;
}
@Override
public double getHeading() {
YawPitchRollAngles angles = imu.getRobotYawPitchRollAngles();
double heading = angles.getYaw(AngleUnit.DEGREES);
// Normalize to 0-359
while (heading < 0) heading += 360;
while (heading >= 360) heading -= 360;
return heading;
}
@Override
public void reset() {
imu.resetYaw();
}
@Override
public boolean isCalibrated() {
return imu.getRobotYawPitchRollAngles() != null;
}
}

View File

@@ -0,0 +1,25 @@
package robot.hardware;
import com.qualcomm.robotcore.hardware.DcMotor;
/**
* FTC SDK implementation of MotorController.
* This wraps the FTC DcMotor class and will only be available when deployed to the robot.
*/
public class FtcMotorController implements MotorController {
private final DcMotor motor;
public FtcMotorController(DcMotor motor) {
this.motor = motor;
}
@Override
public void setPower(double power) {
motor.setPower(power);
}
@Override
public double getPower() {
return motor.getPower();
}
}

View File

@@ -0,0 +1,24 @@
package robot.hardware;
/**
* Interface for gyroscope/IMU sensors.
* Provides heading information for navigation and autonomous driving.
*/
public interface GyroSensor {
/**
* Get the current heading in degrees.
* @return heading from 0-359 degrees (0 = initial orientation)
*/
double getHeading();
/**
* Reset the heading to zero.
*/
void reset();
/**
* Check if the gyro is calibrated and ready.
* @return true if gyro is working properly
*/
boolean isCalibrated();
}

View File

@@ -0,0 +1,27 @@
package robot.hardware;
/**
* Interface for motor control.
* This abstraction allows us to test our logic without actual hardware.
*/
public interface MotorController {
/**
* Set the motor power.
* @param power Power level from -1.0 (full reverse) to 1.0 (full forward)
*/
void setPower(double power);
/**
* Get the current motor power setting.
* @return Current power level
*/
double getPower();
/**
* Check if the motor is currently running.
* @return true if power is non-zero
*/
default boolean isRunning() {
return Math.abs(getPower()) > 0.001;
}
}

View File

@@ -0,0 +1,55 @@
package robot.opmodes;
import com.qualcomm.robotcore.eventloop.opmode.OpMode;
import com.qualcomm.robotcore.eventloop.opmode.TeleOp;
import com.qualcomm.robotcore.hardware.DcMotor;
import robot.hardware.FtcMotorController;
import robot.subsystems.MotorCycler;
/**
* Simple TeleOp mode that cycles a motor on/off.
*
* This demonstrates:
* - Clean separation between hardware abstraction and logic
* - Testable subsystems (MotorCycler can be tested without FTC SDK)
* - Simple, readable OpMode code
*/
@TeleOp(name = "Motor Cycle Demo", group = "Demo")
public class MotorCycleOpMode extends OpMode {
private MotorCycler motorCycler;
@Override
public void init() {
// Get the motor from the hardware map
DcMotor motor = hardwareMap.get(DcMotor.class, "motor");
// Wrap it in our abstraction
FtcMotorController motorController = new FtcMotorController(motor);
// Create the cycler: 2 seconds on, 1 second off
motorCycler = new MotorCycler(motorController, 2000, 1000, 0.5);
motorCycler.init();
telemetry.addData("Status", "Initialized - Ready to cycle motor");
telemetry.addData("Pattern", "2s ON, 1s OFF");
telemetry.update();
}
@Override
public void loop() {
// Update the cycler with current time
motorCycler.update(System.currentTimeMillis());
// Display status
telemetry.addData("Motor State", motorCycler.getState());
telemetry.addData("Time in State", "%.1f seconds",
motorCycler.getTimeInState(System.currentTimeMillis()) / 1000.0);
telemetry.update();
}
@Override
public void stop() {
motorCycler.stop();
}
}

View File

@@ -0,0 +1,105 @@
package robot.subsystems;
import robot.hardware.MotorController;
/**
* Subsystem that cycles a motor on and off with specific timing.
* This demonstrates clean separation between logic and hardware.
*/
public class MotorCycler {
private final MotorController motor;
private final long onDurationMs;
private final long offDurationMs;
private final double motorPower;
private MotorCycleState state;
private long stateStartTime;
public enum MotorCycleState {
ON, OFF
}
/**
* Create a motor cycler with custom timing.
* @param motor The motor to control
* @param onDurationMs How long to run the motor (milliseconds)
* @param offDurationMs How long to pause between runs (milliseconds)
* @param motorPower Power level to use when on (0.0 to 1.0)
*/
public MotorCycler(MotorController motor, long onDurationMs, long offDurationMs, double motorPower) {
this.motor = motor;
this.onDurationMs = onDurationMs;
this.offDurationMs = offDurationMs;
this.motorPower = motorPower;
this.state = MotorCycleState.OFF;
this.stateStartTime = 0;
}
/**
* Create a motor cycler with default power (0.5).
*/
public MotorCycler(MotorController motor, long onDurationMs, long offDurationMs) {
this(motor, onDurationMs, offDurationMs, 0.5);
}
/**
* Initialize the cycler (call once at startup).
*/
public void init() {
state = MotorCycleState.OFF;
stateStartTime = System.currentTimeMillis();
motor.setPower(0.0);
}
/**
* Update the motor state based on elapsed time.
* Call this repeatedly in your main loop.
* @param currentTimeMs Current time in milliseconds
*/
public void update(long currentTimeMs) {
long elapsed = currentTimeMs - stateStartTime;
switch (state) {
case OFF:
if (elapsed >= offDurationMs) {
// Time to turn on
motor.setPower(motorPower);
state = MotorCycleState.ON;
stateStartTime = currentTimeMs;
}
break;
case ON:
if (elapsed >= onDurationMs) {
// Time to turn off
motor.setPower(0.0);
state = MotorCycleState.OFF;
stateStartTime = currentTimeMs;
}
break;
}
}
/**
* Stop the motor and reset to initial state.
*/
public void stop() {
motor.setPower(0.0);
state = MotorCycleState.OFF;
stateStartTime = System.currentTimeMillis();
}
/**
* Get the current cycle state.
*/
public MotorCycleState getState() {
return state;
}
/**
* Get how long we've been in the current state (ms).
*/
public long getTimeInState(long currentTimeMs) {
return currentTimeMs - stateStartTime;
}
}

View File

@@ -0,0 +1,145 @@
package robot.subsystems;
import robot.hardware.GyroSensor;
import robot.hardware.MotorController;
/**
* Subsystem that turns the robot to a target heading using gyro feedback.
*
* This demonstrates closed-loop control:
* - Proportional control (turn faster when far from target)
* - Threshold detection (when close enough to target)
* - Direction selection (shortest path to target)
*
* Pure Java - fully testable without hardware!
*/
public class TurnController {
private final GyroSensor gyro;
private final MotorController leftMotor;
private final MotorController rightMotor;
// Control parameters
private final double HEADING_TOLERANCE = 2.0; // Within 2 degrees = success
private final double MIN_TURN_POWER = 0.15; // Minimum power to overcome friction
private final double MAX_TURN_POWER = 0.5; // Maximum turn speed
private final double KP = 0.02; // Proportional gain
// State
private double targetHeading = 0.0;
private TurnState state = TurnState.IDLE;
public enum TurnState {
IDLE, // Not turning
TURNING, // Actively turning to target
COMPLETE // Reached target heading
}
public TurnController(GyroSensor gyro, MotorController leftMotor, MotorController rightMotor) {
this.gyro = gyro;
this.leftMotor = leftMotor;
this.rightMotor = rightMotor;
}
/**
* Start turning to a target heading.
*
* @param targetDegrees target heading (0-359)
*/
public void turnTo(double targetDegrees) {
this.targetHeading = normalizeHeading(targetDegrees);
this.state = TurnState.TURNING;
}
/**
* Update the turn controller.
* Call this repeatedly in your main loop.
*/
public void update() {
if (state != TurnState.TURNING) {
return; // Not actively turning
}
if (!gyro.isCalibrated()) {
stop();
state = TurnState.IDLE;
return;
}
double currentHeading = gyro.getHeading();
double error = calculateShortestError(currentHeading, targetHeading);
// Check if we've reached the target
if (Math.abs(error) <= HEADING_TOLERANCE) {
stop();
state = TurnState.COMPLETE;
return;
}
// Proportional control: turn power proportional to error
double turnPower = error * KP;
// Clamp to min/max power
if (Math.abs(turnPower) < MIN_TURN_POWER) {
turnPower = Math.signum(turnPower) * MIN_TURN_POWER;
}
if (Math.abs(turnPower) > MAX_TURN_POWER) {
turnPower = Math.signum(turnPower) * MAX_TURN_POWER;
}
// Apply power: positive error = turn right
leftMotor.setPower(turnPower);
rightMotor.setPower(-turnPower);
}
/**
* Stop turning.
*/
public void stop() {
leftMotor.setPower(0.0);
rightMotor.setPower(0.0);
}
/**
* Calculate the shortest angular error between two headings.
* Returns positive for clockwise, negative for counter-clockwise.
*
* Example: current=10, target=350 → error=-20 (turn left 20°)
* current=350, target=10 → error=+20 (turn right 20°)
*/
private double calculateShortestError(double current, double target) {
double error = target - current;
// Normalize to -180 to +180
while (error > 180) error -= 360;
while (error < -180) error += 360;
return error;
}
/**
* Normalize heading to 0-359 range.
*/
private double normalizeHeading(double degrees) {
while (degrees < 0) degrees += 360;
while (degrees >= 360) degrees -= 360;
return degrees;
}
// Getters for testing
public TurnState getState() {
return state;
}
public double getTargetHeading() {
return targetHeading;
}
public double getCurrentHeading() {
return gyro.getHeading();
}
public double getHeadingError() {
return calculateShortestError(gyro.getHeading(), targetHeading);
}
}

View File

@@ -0,0 +1,148 @@
package robot.subsystems;
import robot.hardware.DistanceSensor;
import robot.hardware.MotorController;
/**
* Subsystem that safely approaches a wall using distance sensor feedback.
*
* This demonstrates a real robotics control problem:
* - Drive fast when far away
* - Slow down as you get closer
* - Stop before hitting the wall
* - Handle sensor failures gracefully
*
* This is PURE JAVA - no FTC dependencies!
* Can be tested instantly on Windows without a robot.
*/
public class WallApproach {
// Hardware interfaces (not FTC classes!)
private final DistanceSensor sensor;
private final MotorController leftMotor;
private final MotorController rightMotor;
// Configuration constants
private final double STOP_DISTANCE_CM = 10.0; // Stop 10cm from wall
private final double SLOW_DISTANCE_CM = 30.0; // Start slowing at 30cm
private final double FAST_SPEED = 0.6; // Full speed when far
private final double SLOW_SPEED = 0.2; // Slow speed when near
// State tracking
private WallApproachState state = WallApproachState.INIT;
private boolean sensorError = false;
public enum WallApproachState {
INIT, // Not started
APPROACHING, // Driving toward wall
SLOWING, // Close to wall, slowing down
STOPPED, // At target distance
ERROR // Sensor failure
}
public WallApproach(DistanceSensor sensor, MotorController leftMotor, MotorController rightMotor) {
this.sensor = sensor;
this.leftMotor = leftMotor;
this.rightMotor = rightMotor;
}
/**
* Start the approach sequence.
*/
public void start() {
state = WallApproachState.APPROACHING;
sensorError = false;
}
/**
* Update the approach logic.
* Call this repeatedly in your main loop.
*/
public void update() {
// Check for sensor errors
if (!sensor.isValid()) {
state = WallApproachState.ERROR;
sensorError = true;
stop();
return;
}
double distance = sensor.getDistanceCm();
// State machine logic
switch (state) {
case INIT:
// Do nothing until started
break;
case APPROACHING:
if (distance <= STOP_DISTANCE_CM) {
// Too close - stop immediately!
stop();
state = WallApproachState.STOPPED;
} else if (distance <= SLOW_DISTANCE_CM) {
// Getting close - slow down
setMotors(SLOW_SPEED);
state = WallApproachState.SLOWING;
} else {
// Far away - drive fast
setMotors(FAST_SPEED);
}
break;
case SLOWING:
if (distance <= STOP_DISTANCE_CM) {
// Reached target distance
stop();
state = WallApproachState.STOPPED;
} else if (distance > SLOW_DISTANCE_CM) {
// Drifted backward? Speed up again
setMotors(FAST_SPEED);
state = WallApproachState.APPROACHING;
} else {
// Continue at slow speed
setMotors(SLOW_SPEED);
}
break;
case STOPPED:
// Stay stopped
stop();
break;
case ERROR:
// Stay stopped in error state
stop();
break;
}
}
/**
* Emergency stop.
*/
public void stop() {
leftMotor.setPower(0.0);
rightMotor.setPower(0.0);
}
/**
* Set both motors to the same speed.
*/
private void setMotors(double power) {
leftMotor.setPower(power);
rightMotor.setPower(power);
}
// Getters for testing
public WallApproachState getState() {
return state;
}
public boolean hasSensorError() {
return sensorError;
}
public double getCurrentDistance() {
return sensor.isValid() ? sensor.getDistanceCm() : -1;
}
}

View File

@@ -0,0 +1,84 @@
package robot.hardware;
import java.util.Random;
/**
* Mock implementation of DistanceSensor for testing.
*
* This mock can simulate:
* - Setting specific distances
* - Sensor noise/jitter
* - Sensor failures
* - Gradual distance changes (approaching/retreating)
*
* Example usage in tests:
* MockDistanceSensor sensor = new MockDistanceSensor();
* sensor.setDistance(50.0); // Robot is 50cm from wall
* sensor.addNoise(2.0); // Add ±2cm random noise
*/
public class MockDistanceSensor implements DistanceSensor {
private double distance = 100.0; // Default: far away
private double noiseLevel = 0.0; // Standard deviation of noise
private boolean valid = true;
private Random random = new Random(12345); // Seeded for reproducible tests
/**
* Set the distance reading.
* @param distanceCm distance in centimeters
*/
public void setDistance(double distanceCm) {
this.distance = distanceCm;
}
/**
* Add Gaussian noise to the readings.
* Simulates real-world sensor jitter.
*
* @param stdDev standard deviation of noise in cm
*/
public void setNoise(double stdDev) {
this.noiseLevel = stdDev;
}
/**
* Simulate sensor failure/disconnection.
*/
public void simulateFailure() {
this.valid = false;
}
/**
* Restore sensor to working state.
*/
public void restore() {
this.valid = true;
}
/**
* Simulate gradual approach (like robot driving toward wall).
* @param deltaCm how much closer to get (negative = moving away)
*/
public void approach(double deltaCm) {
this.distance = Math.max(0, this.distance - deltaCm);
}
@Override
public double getDistanceCm() {
if (!valid) {
return -1; // Error value
}
// Add random noise if configured
double noise = 0;
if (noiseLevel > 0) {
noise = random.nextGaussian() * noiseLevel;
}
return distance + noise;
}
@Override
public boolean isValid() {
return valid && distance >= 0;
}
}

View File

@@ -0,0 +1,98 @@
package robot.hardware;
/**
* Mock implementation of GyroSensor for testing.
*
* This mock can simulate:
* - Precise heading control for testing turns
* - Gyro drift over time (realistic behavior)
* - Calibration states
* - Rotation simulation
*
* Example usage:
* MockGyroSensor gyro = new MockGyroSensor();
* gyro.setHeading(90); // Robot facing 90 degrees
* gyro.rotate(45); // Robot turns 45 more degrees
*/
public class MockGyroSensor implements GyroSensor {
private double heading = 0.0;
private boolean calibrated = true;
private double driftPerSecond = 0.0; // Degrees of drift per second
private long lastUpdateTime = System.currentTimeMillis();
/**
* Set the gyro heading directly.
* Useful for setting up test scenarios.
*
* @param degrees heading in degrees (will be normalized to 0-359)
*/
public void setHeading(double degrees) {
this.heading = normalizeHeading(degrees);
this.lastUpdateTime = System.currentTimeMillis();
}
/**
* Simulate the robot rotating.
* Positive = clockwise, negative = counter-clockwise.
*
* @param degrees how many degrees to rotate
*/
public void rotate(double degrees) {
this.heading = normalizeHeading(this.heading + degrees);
this.lastUpdateTime = System.currentTimeMillis();
}
/**
* Simulate gyro drift (realistic behavior).
* Real gyros drift slightly over time.
*
* @param degreesPerSecond how much the gyro drifts per second
*/
public void setDrift(double degreesPerSecond) {
this.driftPerSecond = degreesPerSecond;
}
/**
* Simulate uncalibrated state.
*/
public void setUncalibrated() {
this.calibrated = false;
}
@Override
public double getHeading() {
if (!calibrated) {
return 0.0; // Return zero if not calibrated
}
// Apply drift based on time elapsed
long now = System.currentTimeMillis();
double elapsedSeconds = (now - lastUpdateTime) / 1000.0;
double drift = driftPerSecond * elapsedSeconds;
lastUpdateTime = now;
heading = normalizeHeading(heading + drift);
return heading;
}
@Override
public void reset() {
this.heading = 0.0;
this.lastUpdateTime = System.currentTimeMillis();
}
@Override
public boolean isCalibrated() {
return calibrated;
}
/**
* Normalize heading to 0-359 range.
*/
private double normalizeHeading(double degrees) {
while (degrees < 0) degrees += 360;
while (degrees >= 360) degrees -= 360;
return degrees;
}
}

View File

@@ -0,0 +1,36 @@
package robot.hardware;
/**
* Mock implementation of MotorController for testing.
* Tracks power settings without requiring actual hardware.
*/
public class MockMotorController implements MotorController {
private double power = 0.0;
private int powerSetCount = 0;
@Override
public void setPower(double power) {
this.power = power;
this.powerSetCount++;
}
@Override
public double getPower() {
return power;
}
/**
* Get how many times setPower was called (useful for testing).
*/
public int getPowerSetCount() {
return powerSetCount;
}
/**
* Reset the mock to initial state.
*/
public void reset() {
power = 0.0;
powerSetCount = 0;
}
}

View File

@@ -0,0 +1,310 @@
package robot.subsystems;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import robot.hardware.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* INTEGRATION TEST: Complete autonomous sequence.
*
* This test demonstrates SYSTEM-LEVEL testing without a robot!
*
* Scenario:
* 1. Start 100cm from wall
* 2. Drive straight toward wall
* 3. Stop at 10cm from wall
* 4. Turn 90° right
* 5. Drive forward again
* 6. Turn back to original heading
*
* This tests:
* - Multiple subsystems working together
* - State transitions
* - Sensor coordination
* - Complete mission simulation
*
* ALL WITHOUT A PHYSICAL ROBOT!
*/
@DisplayName("Autonomous Sequence Integration Test")
class AutonomousIntegrationTest {
// Mock hardware
private MockDistanceSensor distanceSensor;
private MockGyroSensor gyro;
private MockMotorController leftMotor;
private MockMotorController rightMotor;
// Subsystems
private WallApproach wallApproach;
private TurnController turnController;
@BeforeEach
void setUp() {
// Create mock hardware (no FTC SDK needed!)
distanceSensor = new MockDistanceSensor();
gyro = new MockGyroSensor();
leftMotor = new MockMotorController();
rightMotor = new MockMotorController();
// Create subsystems
wallApproach = new WallApproach(distanceSensor, leftMotor, rightMotor);
turnController = new TurnController(gyro, leftMotor, rightMotor);
}
@Test
@DisplayName("Full autonomous mission simulation")
void testCompleteAutonomousMission() {
System.out.println("=== Starting Autonomous Mission ===");
// ========== PHASE 1: Approach Wall ==========
System.out.println("\n--- Phase 1: Approaching Wall ---");
distanceSensor.setDistance(100.0); // Start 100cm away
gyro.setHeading(0); // Facing forward
wallApproach.start();
int phaseUpdates = 0;
while (wallApproach.getState() != WallApproach.WallApproachState.STOPPED && phaseUpdates < 200) {
wallApproach.update();
// Simulate robot actually moving
// Movement speed proportional to motor power
double moveSpeed = leftMotor.getPower() * 2.0; // 2cm per update at full power
distanceSensor.approach(moveSpeed);
phaseUpdates++;
// Log state changes
if (phaseUpdates % 20 == 0) {
System.out.printf(" Update %d: State=%s, Distance=%.1fcm, Power=%.2f\n",
phaseUpdates,
wallApproach.getState(),
distanceSensor.getDistanceCm(),
leftMotor.getPower());
}
}
// Verify Phase 1 completed successfully
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState(),
"Phase 1: Should successfully stop at wall");
assertTrue(distanceSensor.getDistanceCm() <= 12.0,
"Phase 1: Should be close to target distance");
System.out.printf("Phase 1 Complete: Stopped at %.1fcm in %d updates\n",
distanceSensor.getDistanceCm(), phaseUpdates);
// ========== PHASE 2: Turn 90° Right ==========
System.out.println("\n--- Phase 2: Turning 90° Right ---");
turnController.turnTo(90);
phaseUpdates = 0;
while (turnController.getState() == TurnController.TurnState.TURNING && phaseUpdates < 200) {
turnController.update();
// Simulate robot actually turning
double turnSpeed = leftMotor.getPower() * 2.0; // 2° per update at full power
gyro.rotate(turnSpeed);
phaseUpdates++;
if (phaseUpdates % 10 == 0) {
System.out.printf(" Update %d: Heading=%.1f°, Error=%.1f°, Power=%.2f\n",
phaseUpdates,
gyro.getHeading(),
turnController.getHeadingError(),
leftMotor.getPower());
}
}
// Verify Phase 2 completed successfully
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState(),
"Phase 2: Turn should complete");
assertTrue(Math.abs(gyro.getHeading() - 90) <= 2.0,
"Phase 2: Should be facing 90°");
System.out.printf("Phase 2 Complete: Turned to %.1f° in %d updates\n",
gyro.getHeading(), phaseUpdates);
// ========== PHASE 3: Drive Forward (new direction) ==========
System.out.println("\n--- Phase 3: Driving Forward (after turn) ---");
// Reset distance sensor for new direction
distanceSensor.setDistance(80.0);
wallApproach.start();
phaseUpdates = 0;
while (wallApproach.getState() != WallApproach.WallApproachState.STOPPED && phaseUpdates < 200) {
wallApproach.update();
double moveSpeed = leftMotor.getPower() * 2.0;
distanceSensor.approach(moveSpeed);
phaseUpdates++;
}
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState(),
"Phase 3: Should stop at wall in new direction");
System.out.printf("Phase 3 Complete: Stopped at %.1fcm\n",
distanceSensor.getDistanceCm());
// ========== PHASE 4: Turn back to original heading ==========
System.out.println("\n--- Phase 4: Returning to Original Heading ---");
turnController.turnTo(0); // Turn back to 0°
phaseUpdates = 0;
while (turnController.getState() == TurnController.TurnState.TURNING && phaseUpdates < 200) {
turnController.update();
double turnSpeed = leftMotor.getPower() * 2.0;
gyro.rotate(turnSpeed);
phaseUpdates++;
}
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState(),
"Phase 4: Should complete return turn");
assertTrue(Math.abs(gyro.getHeading()) <= 2.0,
"Phase 4: Should be back to original heading");
System.out.printf("Phase 4 Complete: Returned to %.1f°\n", gyro.getHeading());
System.out.println("\n=== Mission Complete! ===");
}
@Test
@DisplayName("Mission handles sensor failure gracefully")
void testMissionWithSensorFailure() {
System.out.println("=== Testing Mission with Sensor Failure ===");
// Start approaching wall
distanceSensor.setDistance(50.0);
gyro.setHeading(0);
wallApproach.start();
// Run for a bit
for (int i = 0; i < 10; i++) {
wallApproach.update();
distanceSensor.approach(leftMotor.getPower() * 2.0);
}
// SENSOR FAILS!
System.out.println("--- Simulating sensor failure ---");
distanceSensor.simulateFailure();
wallApproach.update();
// System should detect failure and stop
assertEquals(WallApproach.WallApproachState.ERROR, wallApproach.getState(),
"Should enter error state on sensor failure");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Should stop motors on sensor failure");
assertTrue(wallApproach.hasSensorError(),
"Should report sensor error");
System.out.println("Mission safely aborted due to sensor failure");
}
@Test
@DisplayName("Mission handles unexpected obstacles")
void testMissionWithObstacle() {
System.out.println("=== Testing Mission with Unexpected Obstacle ===");
// Start normal approach
distanceSensor.setDistance(100.0);
wallApproach.start();
// Robot driving toward wall
for (int i = 0; i < 20; i++) {
wallApproach.update();
distanceSensor.approach(leftMotor.getPower() * 2.0);
}
// UNEXPECTED OBSTACLE APPEARS!
System.out.println("--- Obstacle detected! ---");
distanceSensor.setDistance(8.0); // Suddenly very close!
wallApproach.update();
// Should immediately stop
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState(),
"Should immediately stop when obstacle detected");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Motors should stop");
System.out.println("Emergency stop successful");
}
@Test
@DisplayName("Multi-waypoint navigation")
void testMultiWaypointNavigation() {
System.out.println("=== Testing Multi-Waypoint Navigation ===");
// Simulate driving to multiple waypoints:
// 1. Drive forward, turn 90° right
// 2. Drive forward, turn 90° right
// 3. Drive forward, turn 90° right
// 4. Drive forward, turn 90° right
// = Square pattern!
gyro.setHeading(0);
for (int waypoint = 1; waypoint <= 4; waypoint++) {
System.out.printf("\n--- Waypoint %d ---\n", waypoint);
// Drive forward
distanceSensor.setDistance(50.0);
wallApproach.start();
while (wallApproach.getState() != WallApproach.WallApproachState.STOPPED) {
wallApproach.update();
distanceSensor.approach(leftMotor.getPower() * 2.0);
}
System.out.printf("Reached waypoint %d\n", waypoint);
// Turn 90° right
double targetHeading = (waypoint * 90) % 360;
turnController.turnTo(targetHeading);
while (turnController.getState() == TurnController.TurnState.TURNING) {
turnController.update();
gyro.rotate(leftMotor.getPower() * 2.0);
}
System.out.printf("Turned to %.0f°\n", gyro.getHeading());
}
// Should complete the square and face original direction
assertTrue(Math.abs(gyro.getHeading()) <= 2.0 ||
Math.abs(gyro.getHeading() - 360) <= 2.0,
"Should complete square and face original direction");
System.out.println("\nSquare pattern complete!");
}
@Test
@DisplayName("Concurrent sensor updates")
void testConcurrentSensorUpdates() {
// Test that system handles sensors updating at different rates
distanceSensor.setDistance(50.0);
gyro.setHeading(0);
wallApproach.start();
// Simulate 100 updates where sensors might not always have new data
for (int i = 0; i < 100; i++) {
// Distance sensor updates every cycle
wallApproach.update();
distanceSensor.approach(leftMotor.getPower() * 1.0);
// Gyro might update less frequently (every 3 cycles)
if (i % 3 == 0) {
gyro.rotate(0.1); // Slight drift
}
// System should remain stable
assertNotEquals(WallApproach.WallApproachState.ERROR, wallApproach.getState(),
"System should remain stable with varying sensor update rates");
}
}
}

View File

@@ -0,0 +1,160 @@
package robot.subsystems;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import robot.hardware.MockMotorController;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for MotorCycler subsystem.
* These tests run on the PC without requiring FTC SDK or Android.
*/
class MotorCyclerTest {
private MockMotorController motor;
private MotorCycler cycler;
@BeforeEach
void setUp() {
motor = new MockMotorController();
// Create cycler: 100ms on, 50ms off, 0.75 power
cycler = new MotorCycler(motor, 100, 50, 0.75);
}
@Test
void testInitialization() {
cycler.init();
assertEquals(0.0, motor.getPower(), 0.001,
"Motor should be off after init");
assertEquals(MotorCycler.MotorCycleState.OFF, cycler.getState(),
"Should start in OFF state");
}
@Test
void testFirstCycle_TurnsOnAfterOffPeriod() {
cycler.init();
long startTime = System.currentTimeMillis();
// Should stay off during the off period
cycler.update(startTime + 25);
assertEquals(0.0, motor.getPower(), 0.001);
assertEquals(MotorCycler.MotorCycleState.OFF, cycler.getState());
// Should turn on after off period completes
cycler.update(startTime + 50);
assertEquals(0.75, motor.getPower(), 0.001,
"Motor should turn on after off period");
assertEquals(MotorCycler.MotorCycleState.ON, cycler.getState());
}
@Test
void testCycleFromOnToOff() {
cycler.init();
long startTime = System.currentTimeMillis();
// Skip to motor being on
cycler.update(startTime + 50);
assertEquals(MotorCycler.MotorCycleState.ON, cycler.getState());
// Should stay on during on period
cycler.update(startTime + 100);
assertEquals(0.75, motor.getPower(), 0.001);
assertEquals(MotorCycler.MotorCycleState.ON, cycler.getState());
// Should turn off after on period completes
cycler.update(startTime + 150);
assertEquals(0.0, motor.getPower(), 0.001,
"Motor should turn off after on period");
assertEquals(MotorCycler.MotorCycleState.OFF, cycler.getState());
}
@Test
void testFullCycle() {
cycler.init();
long time = System.currentTimeMillis();
// Start OFF
cycler.update(time);
assertEquals(MotorCycler.MotorCycleState.OFF, cycler.getState());
assertEquals(0.0, motor.getPower(), 0.001);
// After 50ms: turn ON
time += 50;
cycler.update(time);
assertEquals(MotorCycler.MotorCycleState.ON, cycler.getState());
assertEquals(0.75, motor.getPower(), 0.001);
// After another 100ms: turn OFF
time += 100;
cycler.update(time);
assertEquals(MotorCycler.MotorCycleState.OFF, cycler.getState());
assertEquals(0.0, motor.getPower(), 0.001);
// After another 50ms: turn ON again
time += 50;
cycler.update(time);
assertEquals(MotorCycler.MotorCycleState.ON, cycler.getState());
assertEquals(0.75, motor.getPower(), 0.001);
}
@Test
void testTimeInState() {
cycler.init();
long startTime = System.currentTimeMillis();
// Check time in initial OFF state
assertEquals(0, cycler.getTimeInState(startTime));
assertEquals(25, cycler.getTimeInState(startTime + 25));
// Transition to ON
cycler.update(startTime + 50);
assertEquals(0, cycler.getTimeInState(startTime + 50));
assertEquals(30, cycler.getTimeInState(startTime + 80));
}
@Test
void testStop() {
cycler.init();
long time = System.currentTimeMillis();
// Get motor running
cycler.update(time + 50);
assertEquals(MotorCycler.MotorCycleState.ON, cycler.getState());
assertEquals(0.75, motor.getPower(), 0.001);
// Stop should turn off motor and reset to OFF state
cycler.stop();
assertEquals(MotorCycler.MotorCycleState.OFF, cycler.getState());
assertEquals(0.0, motor.getPower(), 0.001);
}
@Test
void testDefaultPower() {
// Create cycler with default power
MotorCycler defaultCycler = new MotorCycler(motor, 100, 50);
defaultCycler.init();
long time = System.currentTimeMillis();
// Skip to ON state
defaultCycler.update(time + 50);
// Should use default power of 0.5
assertEquals(0.5, motor.getPower(), 0.001,
"Default power should be 0.5");
}
@Test
void testMotorControllerIsRunning() {
motor.setPower(0.0);
assertFalse(motor.isRunning(), "Motor with 0 power should not be running");
motor.setPower(0.5);
assertTrue(motor.isRunning(), "Motor with positive power should be running");
motor.setPower(-0.3);
assertTrue(motor.isRunning(), "Motor with negative power should be running");
motor.setPower(0.0001);
assertFalse(motor.isRunning(), "Motor with tiny power should not be running");
}
}

View File

@@ -0,0 +1,383 @@
package robot.subsystems;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import robot.hardware.MockGyroSensor;
import robot.hardware.MockMotorController;
import static org.junit.jupiter.api.Assertions.*;
/**
* Comprehensive tests for TurnController subsystem.
*
* Tests cover:
* - Basic turn mechanics
* - Shortest path selection (clockwise vs counter-clockwise)
* - Proportional control behavior
* - Gyro drift handling
* - 360-degree wraparound cases
*/
@DisplayName("Turn Controller Subsystem Tests")
class TurnControllerTest {
private MockGyroSensor gyro;
private MockMotorController leftMotor;
private MockMotorController rightMotor;
private TurnController turnController;
@BeforeEach
void setUp() {
gyro = new MockGyroSensor();
leftMotor = new MockMotorController();
rightMotor = new MockMotorController();
turnController = new TurnController(gyro, leftMotor, rightMotor);
}
// ========== UNIT TESTS: Basic Functionality ==========
@Test
@DisplayName("Unit: Initial state is IDLE")
void testInitialState() {
assertEquals(TurnController.TurnState.IDLE, turnController.getState(),
"Turn controller should start in IDLE state");
}
@Test
@DisplayName("Unit: turnTo() starts turning")
void testTurnToStartsTurning() {
turnController.turnTo(90);
assertEquals(TurnController.TurnState.TURNING, turnController.getState(),
"Should enter TURNING state after turnTo()");
assertEquals(90, turnController.getTargetHeading(), 0.001,
"Target heading should be set");
}
@Test
@DisplayName("Unit: Completes when within tolerance")
void testCompletesWithinTolerance() {
gyro.setHeading(88.5); // Very close to 90
turnController.turnTo(90);
turnController.update();
// Should complete (within 2 degree tolerance)
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState(),
"Should complete when within 2 degrees of target");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Motors should stop when turn complete");
}
// ========== SHORTEST PATH TESTS ==========
@Test
@DisplayName("Path: Simple clockwise turn (0° → 90°)")
void testSimpleClockwiseTurn() {
gyro.setHeading(0);
turnController.turnTo(90);
turnController.update();
// Should turn right (positive power on left motor)
assertTrue(leftMotor.getPower() > 0,
"Left motor should be positive for clockwise turn");
assertTrue(rightMotor.getPower() < 0,
"Right motor should be negative for clockwise turn");
}
@Test
@DisplayName("Path: Simple counter-clockwise turn (90° → 0°)")
void testSimpleCounterClockwiseTurn() {
gyro.setHeading(90);
turnController.turnTo(0);
turnController.update();
// Should turn left (negative power on left motor)
assertTrue(leftMotor.getPower() < 0,
"Left motor should be negative for counter-clockwise turn");
assertTrue(rightMotor.getPower() > 0,
"Right motor should be positive for counter-clockwise turn");
}
@Test
@DisplayName("Path: Wraparound clockwise (350° → 10°)")
void testWraparoundClockwise() {
// Currently at 350°, want to turn to 10°
// Shortest path is clockwise through 0° (20° turn)
gyro.setHeading(350);
turnController.turnTo(10);
double error = turnController.getHeadingError();
// Error should be positive (clockwise)
assertTrue(error > 0,
"Should choose clockwise path (positive error)");
assertEquals(20, error, 0.001,
"Shortest path from 350° to 10° is 20° clockwise");
turnController.update();
assertTrue(leftMotor.getPower() > 0,
"Should turn clockwise");
}
@Test
@DisplayName("Path: Wraparound counter-clockwise (10° → 350°)")
void testWraparoundCounterClockwise() {
// Currently at 10°, want to turn to 350°
// Shortest path is counter-clockwise through 0° (20° turn)
gyro.setHeading(10);
turnController.turnTo(350);
double error = turnController.getHeadingError();
// Error should be negative (counter-clockwise)
assertTrue(error < 0,
"Should choose counter-clockwise path (negative error)");
assertEquals(-20, error, 0.001,
"Shortest path from 10° to 350° is 20° counter-clockwise");
turnController.update();
assertTrue(leftMotor.getPower() < 0,
"Should turn counter-clockwise");
}
@Test
@DisplayName("Path: Exactly opposite heading (180° ambiguous)")
void testOppositeHeading() {
gyro.setHeading(0);
turnController.turnTo(180);
double error = turnController.getHeadingError();
// Either direction is valid, should pick one consistently
assertEquals(180, Math.abs(error), 0.001,
"180° turn should be exactly 180° either direction");
}
// ========== PROPORTIONAL CONTROL TESTS ==========
@Test
@DisplayName("Control: Turn power proportional to error")
void testProportionalControl() {
// Large error should produce large turn power
gyro.setHeading(0);
turnController.turnTo(90);
turnController.update();
double largePower = Math.abs(leftMotor.getPower());
// Small error should produce small turn power
leftMotor.setPower(0); // Reset
gyro.setHeading(85);
turnController.update();
double smallPower = Math.abs(leftMotor.getPower());
assertTrue(largePower > smallPower,
"Larger heading error should produce larger turn power");
}
@Test
@DisplayName("Control: Minimum power enforced")
void testMinimumPower() {
// Very small error (but not within tolerance)
gyro.setHeading(88); // 2° away, at tolerance threshold
turnController.turnTo(90);
turnController.update();
// If not complete, should use minimum power (0.15)
if (turnController.getState() == TurnController.TurnState.TURNING) {
assertTrue(Math.abs(leftMotor.getPower()) >= 0.15,
"Should enforce minimum turn power to overcome friction");
}
}
@Test
@DisplayName("Control: Maximum power capped")
void testMaximumPower() {
// Very large error
gyro.setHeading(0);
turnController.turnTo(179); // Almost opposite
turnController.update();
// Power should be capped at 0.5
assertTrue(Math.abs(leftMotor.getPower()) <= 0.5,
"Turn power should be capped at maximum");
}
// ========== SYSTEM TESTS: Complete Turns ==========
@Test
@DisplayName("System: Complete 90° turn")
void testComplete90DegreeTurn() {
gyro.setHeading(0);
turnController.turnTo(90);
// Simulate turning (gyro updates as robot turns)
int maxIterations = 200; // Increased from 100
int iteration = 0;
while (turnController.getState() == TurnController.TurnState.TURNING && iteration < maxIterations) {
turnController.update();
// Simulate robot actually turning
// Turn speed proportional to motor power
double turnSpeed = leftMotor.getPower() * 3.0; // Increased from 2.0 for faster simulation
gyro.rotate(turnSpeed);
iteration++;
}
// Should complete
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState(),
"Turn should complete");
assertTrue(Math.abs(gyro.getHeading() - 90) <= 2.0,
"Should be within 2° of target");
assertTrue(iteration < maxIterations,
"Should complete in reasonable time");
}
@Test
@DisplayName("System: Complete wraparound turn")
void testCompleteWraparoundTurn() {
gyro.setHeading(350);
turnController.turnTo(10);
int maxIterations = 100;
int iteration = 0;
while (turnController.getState() == TurnController.TurnState.TURNING && iteration < maxIterations) {
turnController.update();
double turnSpeed = leftMotor.getPower() * 2.0;
gyro.rotate(turnSpeed);
iteration++;
}
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState());
// Should be at ~10°
double finalHeading = gyro.getHeading();
assertTrue(Math.abs(finalHeading - 10) <= 2.0,
"Should complete wraparound turn accurately");
}
// ========== EDGE CASE TESTS ==========
@Test
@DisplayName("Edge: Handles uncalibrated gyro")
void testUncalibratedGyro() {
gyro.setUncalibrated();
turnController.turnTo(90);
turnController.update();
// Should stop and return to idle
assertEquals(TurnController.TurnState.IDLE, turnController.getState(),
"Should not turn with uncalibrated gyro");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Motors should stop with uncalibrated gyro");
}
@Test
@DisplayName("Edge: Handles gyro drift during turn")
void testGyrodrift() {
gyro.setHeading(0);
gyro.setDrift(0.5); // 0.5° per second drift
turnController.turnTo(90);
// Simulate turn with drift
for (int i = 0; i < 50; i++) {
turnController.update();
double turnSpeed = leftMotor.getPower() * 2.0;
gyro.rotate(turnSpeed);
// Drift adds a bit each update
try {
Thread.sleep(10); // 10ms per update
} catch (InterruptedException e) {
// Ignore
}
}
// Should still complete despite drift
// (Controller will compensate for drift)
assertTrue(turnController.getState() == TurnController.TurnState.COMPLETE ||
turnController.getState() == TurnController.TurnState.TURNING,
"Should handle drift gracefully");
}
@Test
@DisplayName("Edge: Multiple turns in sequence")
void testSequentialTurns() {
gyro.setHeading(0);
// First turn: 0 → 90
turnController.turnTo(90);
simulateTurn();
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState());
// Second turn: 90 → 180
turnController.turnTo(180);
simulateTurn();
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState());
// Third turn: 180 → 0 (shortest is through 270)
turnController.turnTo(0);
simulateTurn();
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState());
assertTrue(Math.abs(gyro.getHeading()) <= 2.0,
"Should complete all turns accurately");
}
@Test
@DisplayName("Edge: Manual stop during turn")
void testManualStopDuringTurn() {
gyro.setHeading(0);
turnController.turnTo(90);
turnController.update();
// Motors should be running
assertTrue(Math.abs(leftMotor.getPower()) > 0);
// Manual stop
turnController.stop();
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Stop should immediately halt motors");
assertEquals(0.0, rightMotor.getPower(), 0.001);
}
@Test
@DisplayName("Edge: Turn to current heading (no-op)")
void testTurnToCurrentHeading() {
gyro.setHeading(45);
turnController.turnTo(45);
turnController.update();
// Should immediately complete
assertEquals(TurnController.TurnState.COMPLETE, turnController.getState(),
"Turning to current heading should immediately complete");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"No motor power needed");
}
// ========== HELPER METHODS ==========
/**
* Helper to simulate a turn completing.
*/
private void simulateTurn() {
int maxIterations = 300; // Increased from 200
int iteration = 0;
while (turnController.getState() == TurnController.TurnState.TURNING && iteration < maxIterations) {
turnController.update();
double turnSpeed = leftMotor.getPower() * 3.0; // Match testComplete90DegreeTurn
gyro.rotate(turnSpeed);
iteration++;
}
assertTrue(iteration < maxIterations,
"Turn should complete in reasonable time");
}
}

View File

@@ -0,0 +1,352 @@
package robot.subsystems;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import robot.hardware.MockDistanceSensor;
import robot.hardware.MockMotorController;
import static org.junit.jupiter.api.Assertions.*;
/**
* Comprehensive tests for WallApproach subsystem.
*
* These tests demonstrate:
* - Unit testing (individual behaviors)
* - System testing (complete scenarios)
* - Edge case testing (sensor failures, noise)
* - State machine testing
*
* All tests run on Windows JRE - no robot needed!
*/
@DisplayName("Wall Approach Subsystem Tests")
class WallApproachTest {
private MockDistanceSensor sensor;
private MockMotorController leftMotor;
private MockMotorController rightMotor;
private WallApproach wallApproach;
@BeforeEach
void setUp() {
// Create mock hardware (no FTC SDK needed!)
sensor = new MockDistanceSensor();
leftMotor = new MockMotorController();
rightMotor = new MockMotorController();
// Create the subsystem we're testing
wallApproach = new WallApproach(sensor, leftMotor, rightMotor);
}
// ========== UNIT TESTS: Individual Behaviors ==========
@Test
@DisplayName("Unit: Initial state should be INIT")
void testInitialState() {
assertEquals(WallApproach.WallApproachState.INIT, wallApproach.getState(),
"Wall approach should start in INIT state");
}
@Test
@DisplayName("Unit: Starting approach transitions to APPROACHING state")
void testStartTransition() {
sensor.setDistance(100.0); // Far from wall
wallApproach.start();
assertEquals(WallApproach.WallApproachState.APPROACHING, wallApproach.getState(),
"After start(), should be in APPROACHING state");
}
@Test
@DisplayName("Unit: Drives at full speed when far from wall")
void testFullSpeedWhenFar() {
sensor.setDistance(100.0); // 100cm = far away
wallApproach.start();
wallApproach.update();
// Should drive at full speed (0.6)
assertEquals(0.6, leftMotor.getPower(), 0.001,
"Left motor should be at full speed when far from wall");
assertEquals(0.6, rightMotor.getPower(), 0.001,
"Right motor should be at full speed when far from wall");
assertEquals(WallApproach.WallApproachState.APPROACHING, wallApproach.getState());
}
@Test
@DisplayName("Unit: Slows down when approaching threshold")
void testSlowsDownNearWall() {
sensor.setDistance(25.0); // 25cm = within slow zone (< 30cm)
wallApproach.start();
wallApproach.update();
// Should slow down to 0.2
assertEquals(0.2, leftMotor.getPower(), 0.001,
"Should slow to 0.2 when within 30cm of wall");
assertEquals(0.2, rightMotor.getPower(), 0.001);
assertEquals(WallApproach.WallApproachState.SLOWING, wallApproach.getState());
}
@Test
@DisplayName("Unit: Stops at target distance")
void testStopsAtTarget() {
sensor.setDistance(10.0); // Exactly at stop distance
wallApproach.start();
wallApproach.update();
// Should stop
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Should stop when reaching target distance");
assertEquals(0.0, rightMotor.getPower(), 0.001);
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState());
}
// ========== SYSTEM TESTS: Complete Scenarios ==========
@Test
@DisplayName("System: Complete approach from far to stopped")
void testCompleteApproachSequence() {
// Start far away
sensor.setDistance(100.0);
wallApproach.start();
// Phase 1: Fast approach (100cm → 35cm)
for (int i = 0; i < 13; i++) { // 13 updates at 5cm each
wallApproach.update();
assertEquals(0.6, leftMotor.getPower(), 0.001,
"Should maintain full speed while far");
sensor.approach(5.0); // Get 5cm closer each update
}
// Now at ~35cm - one more update should trigger slowing
wallApproach.update();
sensor.approach(5.0); // Now at 30cm
// Phase 2: Slow approach (30cm → 10cm)
wallApproach.update();
assertEquals(WallApproach.WallApproachState.SLOWING, wallApproach.getState(),
"Should be slowing when distance < 30cm");
assertEquals(0.2, leftMotor.getPower(), 0.001,
"Should be at slow speed");
for (int i = 0; i < 4; i++) { // 4 updates at 5cm each
wallApproach.update();
assertEquals(0.2, leftMotor.getPower(), 0.001);
sensor.approach(5.0);
}
// Phase 3: Stop at target
wallApproach.update();
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState(),
"Should stop at 10cm");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Motors should be stopped");
}
@Test
@DisplayName("System: Handles sensor noise gracefully")
void testHandlesSensorNoise() {
sensor.setDistance(50.0);
sensor.setNoise(2.0); // Add ±2cm noise
wallApproach.start();
// Run 20 updates with noisy sensor
for (int i = 0; i < 20; i++) {
wallApproach.update();
sensor.approach(0.5); // Get slightly closer each time
// Should not crash or behave erratically
assertTrue(leftMotor.getPower() >= 0,
"Motor power should never be negative");
assertTrue(leftMotor.getPower() <= 1.0,
"Motor power should never exceed 1.0");
}
// Should still be in a valid state
assertNotEquals(WallApproach.WallApproachState.ERROR, wallApproach.getState(),
"Should not enter error state with valid noisy sensor");
}
@Test
@DisplayName("System: Emergency stop if too close initially")
void testEmergencyStopIfTooClose() {
// Robot is already too close!
sensor.setDistance(5.0);
wallApproach.start();
wallApproach.update();
// Should immediately stop
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState(),
"Should immediately stop if already too close");
assertEquals(0.0, leftMotor.getPower(), 0.001);
assertEquals(0.0, rightMotor.getPower(), 0.001);
}
// ========== EDGE CASE TESTS ==========
@Test
@DisplayName("Edge: Handles sensor failure")
void testSensorFailureHandling() {
sensor.setDistance(50.0);
wallApproach.start();
wallApproach.update();
// Motors should be running
assertTrue(leftMotor.getPower() > 0);
// Sensor fails!
sensor.simulateFailure();
wallApproach.update();
// Should enter error state and stop
assertEquals(WallApproach.WallApproachState.ERROR, wallApproach.getState(),
"Should enter ERROR state on sensor failure");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Should stop motors on sensor failure");
assertTrue(wallApproach.hasSensorError(),
"Should report sensor error");
}
@Test
@DisplayName("Edge: Recovers if pushed backward")
void testRecoveryFromBackwardMotion() {
// Start in slow zone
sensor.setDistance(25.0);
wallApproach.start();
wallApproach.update();
assertEquals(WallApproach.WallApproachState.SLOWING, wallApproach.getState());
assertEquals(0.2, leftMotor.getPower(), 0.001);
// Robot gets pushed backward (human intervention, etc.)
sensor.setDistance(35.0);
wallApproach.update();
// Should speed up again
assertEquals(WallApproach.WallApproachState.APPROACHING, wallApproach.getState(),
"Should transition back to APPROACHING if pushed back");
assertEquals(0.6, leftMotor.getPower(), 0.001,
"Should speed up when far again");
}
@Test
@DisplayName("Edge: Stays stopped once reached")
void testStaysStoppedOnceReached() {
sensor.setDistance(10.0);
wallApproach.start();
wallApproach.update();
// Should be stopped
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState());
// Run multiple more updates - should stay stopped
for (int i = 0; i < 10; i++) {
wallApproach.update();
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState(),
"Should remain stopped");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Motors should stay off");
}
}
@Test
@DisplayName("Edge: Manual stop works in any state")
void testManualStop() {
sensor.setDistance(50.0);
wallApproach.start();
wallApproach.update();
// Motors running
assertTrue(leftMotor.getPower() > 0);
// Manual stop
wallApproach.stop();
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Stop should immediately halt motors");
assertEquals(0.0, rightMotor.getPower(), 0.001);
}
@Test
@DisplayName("Edge: Threshold boundary behavior")
void testThresholdBoundaries() {
// Test exact boundary values
// Just above slow threshold (30cm)
sensor.setDistance(30.1);
wallApproach.start();
wallApproach.update();
assertEquals(0.6, leftMotor.getPower(), 0.001,
"At 30.1cm should still be full speed");
// Just below slow threshold
sensor.setDistance(29.9);
wallApproach.update();
assertEquals(0.2, leftMotor.getPower(), 0.001,
"At 29.9cm should be slow speed");
// Just above stop threshold (10cm)
sensor.setDistance(10.1);
wallApproach.update();
assertEquals(0.2, leftMotor.getPower(), 0.001,
"At 10.1cm should still be moving slowly");
// Just below stop threshold
sensor.setDistance(9.9);
wallApproach.update();
assertEquals(0.0, leftMotor.getPower(), 0.001,
"At 9.9cm should be stopped");
}
// ========== INTEGRATION TEST ==========
@Test
@DisplayName("Integration: Full realistic approach with noise and variance")
void testRealisticApproachScenario() {
// Simulate a realistic approach with:
// - Sensor noise
// - Non-uniform distance changes
// - Multiple updates per cm traveled
sensor.setDistance(80.0);
sensor.setNoise(1.5); // Realistic noise level
wallApproach.start();
int updateCount = 0;
WallApproach.WallApproachState lastState = wallApproach.getState();
// Simulate approach with varying speeds
while (sensor.getDistanceCm() > 10.0 && updateCount < 300) { // Increased from 200
wallApproach.update();
updateCount++;
// Approach speed varies (not constant)
if (sensor.getDistanceCm() > 30) {
sensor.approach(1.5); // Reduced from 2.0 for more realistic simulation
} else {
sensor.approach(0.3); // Reduced from 0.5 for slower approach
}
// Track state transitions
WallApproach.WallApproachState currentState = wallApproach.getState();
if (currentState != lastState) {
System.out.println("State transition: " + lastState + "" + currentState +
" at " + String.format("%.1f", sensor.getDistanceCm()) + "cm");
lastState = currentState;
}
}
// Should have completed successfully
assertEquals(WallApproach.WallApproachState.STOPPED, wallApproach.getState(),
"Should successfully stop at target distance");
assertTrue(sensor.getDistanceCm() <= 11.0,
"Should stop very close to target (within noise tolerance)");
assertEquals(0.0, leftMotor.getPower(), 0.001,
"Motors should be stopped at end");
}
}