[commands] Add Commands v3 framework (#6518)

The framework fundamentally relies on the continuation API added in Java 21 (which is currently internal to the JDK). Continuations allow for call stacks to be saved to the heap and resumed later.

The async framework allows command bodies to be written in an imperative style. However, an async command will need to be actively cooperative and periodically call coroutine.yield() in loops to yield control back to the command scheduler to let it process other commands.

There are also some other additions like priority levels (as opposed to a blanket yes/no for ignoring incoming commands), factories requiring names be provided for commands, and the scheduler tracking all running commands and not just the highest-level groups. However, those changes aren't unique to an async framework, and could just as easily be used in a traditional command framework.
This commit is contained in:
Sam Carlberg
2025-10-10 16:47:22 -04:00
committed by GitHub
parent 33f91589b4
commit b37e2d9343
77 changed files with 12259 additions and 3 deletions

View File

@@ -0,0 +1,23 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import edu.wpi.first.wpilibj.RobotController;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
class CommandTestBase {
protected Scheduler m_scheduler;
protected List<SchedulerEvent> m_events;
@BeforeEach
void initScheduler() {
RobotController.setTimeSource(() -> System.nanoTime() / 1000L);
m_scheduler = Scheduler.createIndependentScheduler();
m_events = new ArrayList<>();
m_scheduler.addEventListener(m_events::add);
}
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.wpilib.commands3.ConflictDetector.findAllConflicts;
import static org.wpilib.commands3.ConflictDetector.throwIfConflicts;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.wpilib.commands3.ConflictDetector.Conflict;
class ConflictDetectorTest extends CommandTestBase {
@Test
void emptyInputHasNoConflicts() {
var conflicts = findAllConflicts(List.of());
assertEquals(0, conflicts.size());
}
@Test
void singleInputHasNoConflicts() {
var mech = new Mechanism("Mech", m_scheduler);
var command = Command.requiring(mech).executing(Coroutine::park).named("Command");
var conflicts = findAllConflicts(List.of(command));
assertEquals(0, conflicts.size());
}
@Test
void commandDoesNotConflictWithSelf() {
var mech = new Mechanism("Mech", m_scheduler);
var command = Command.requiring(mech).executing(Coroutine::park).named("Command");
var conflicts = findAllConflicts(List.of(command, command));
assertEquals(0, conflicts.size());
}
@Test
void detectManyConflicts() {
var mech1 = new Mechanism("Mech 1", m_scheduler);
var mech2 = new Mechanism("Mech 2", m_scheduler);
var command1 = Command.requiring(mech1, mech2).executing(Coroutine::park).named("Command1");
var command2 = Command.requiring(mech1).executing(Coroutine::park).named("Command2");
var command3 = Command.requiring(mech2).executing(Coroutine::park).named("Command3");
var command4 = Command.requiring(mech2, mech1).executing(Coroutine::park).named("Command4");
var allCommands = List.of(command1, command2, command3, command4);
var conflicts = findAllConflicts(allCommands);
assertEquals(5, conflicts.size(), "Five conflicting pairs should have been found");
assertEquals(new Conflict(command1, command2, Set.of(mech1)), conflicts.get(0));
assertEquals(new Conflict(command1, command3, Set.of(mech2)), conflicts.get(1));
assertEquals(new Conflict(command1, command4, Set.of(mech1, mech2)), conflicts.get(2));
assertEquals(new Conflict(command2, command4, Set.of(mech1)), conflicts.get(3));
assertEquals(new Conflict(command3, command4, Set.of(mech2)), conflicts.get(4));
// error messaging
var error = assertThrows(IllegalArgumentException.class, () -> throwIfConflicts(allCommands));
assertEquals(
"Commands running in parallel cannot share requirements: "
+ "Command1 and Command2 both require Mech 1; "
+ "Command1 and Command3 both require Mech 2; "
+ "Command1 and Command4 both require Mech 1, Mech 2; "
+ "Command2 and Command4 both require Mech 1; "
+ "Command3 and Command4 both require Mech 2",
error.getMessage());
}
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.junit.jupiter.api.Test;
class CoroutineTest extends CommandTestBase {
@Test
void forkMany() {
var a = new NullCommand();
var b = new NullCommand();
var c = new NullCommand();
var all =
Command.noRequirements()
.executing(
co -> {
co.fork(a, b, c);
co.park();
})
.named("Fork Many");
m_scheduler.schedule(all);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(a));
assertTrue(m_scheduler.isRunning(b));
assertTrue(m_scheduler.isRunning(c));
}
@Test
void yieldInSynchronizedBlock() {
Object mutex = new Object();
AtomicInteger i = new AtomicInteger(0);
var yieldInSynchronized =
Command.noRequirements()
.executing(
co -> {
while (true) {
synchronized (mutex) {
i.incrementAndGet();
co.yield();
}
}
})
.named("Yield In Synchronized Block");
m_scheduler.schedule(yieldInSynchronized);
var error = assertThrows(IllegalStateException.class, m_scheduler::run);
assertEquals(
"Coroutine.yield() cannot be called inside a synchronized block or method. "
+ "Consider using a Lock instead of synchronized, "
+ "or rewrite your code to avoid locks and mutexes altogether.",
error.getMessage());
}
@Test
void yieldInLockBody() {
Lock lock = new ReentrantLock();
AtomicInteger i = new AtomicInteger(0);
var yieldInLock =
Command.noRequirements()
.executing(
co -> {
while (true) {
lock.lock();
try {
i.incrementAndGet();
co.yield();
} finally {
lock.unlock();
}
}
})
.named("Increment In Lock Block");
m_scheduler.schedule(yieldInLock);
m_scheduler.run();
assertEquals(1, i.get());
}
@Test
void coroutineEscapingCommand() {
AtomicReference<Runnable> escapeeCallback = new AtomicReference<>();
var badCommand =
Command.noRequirements()
.executing(
co -> {
escapeeCallback.set(co::yield);
})
.named("Bad Command");
m_scheduler.schedule(badCommand);
m_scheduler.run();
var error = assertThrows(IllegalStateException.class, escapeeCallback.get()::run);
assertEquals("Coroutines can only be used by the command bound to them", error.getMessage());
}
@Test
void usingParentCoroutineInChildThrows() {
var parent =
Command.noRequirements()
.executing(
parentCoroutine -> {
parentCoroutine.await(
Command.noRequirements()
.executing(
childCoroutine -> {
parentCoroutine.yield();
})
.named("Child"));
})
.named("Parent");
m_scheduler.schedule(parent);
var error = assertThrows(IllegalStateException.class, m_scheduler::run);
assertEquals("Coroutines can only be used by the command bound to them", error.getMessage());
}
@Test
void awaitAnyCleansUp() {
AtomicBoolean firstRan = new AtomicBoolean(false);
AtomicBoolean secondRan = new AtomicBoolean(false);
AtomicBoolean ranAfterAwait = new AtomicBoolean(false);
var firstInner = Command.noRequirements().executing(c2 -> firstRan.set(true)).named("First");
var secondInner =
Command.noRequirements()
.executing(
c2 -> {
secondRan.set(true);
c2.park();
})
.named("Second");
var outer =
Command.noRequirements()
.executing(
co -> {
co.awaitAny(firstInner, secondInner);
ranAfterAwait.set(true);
co.park(); // prevent exiting
})
.named("Command");
m_scheduler.schedule(outer);
m_scheduler.run();
// Everything should have run...
assertTrue(firstRan.get());
assertTrue(secondRan.get());
assertTrue(ranAfterAwait.get());
// But only the outer command should still be running; secondInner should have been canceled
assertEquals(Set.of(outer), m_scheduler.getRunningCommands());
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import java.util.Set;
class NullCommand implements Command {
@Override
public void run(Coroutine coroutine) {
coroutine.park();
}
@Override
public String name() {
return "Null Command";
}
@Override
public Set<Mechanism> requirements() {
return Set.of();
}
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class ParallelGroupTest extends CommandTestBase {
@Test
void parallelAll() {
var r1 = new Mechanism("R1", m_scheduler);
var r2 = new Mechanism("R2", m_scheduler);
var c1Count = new AtomicInteger(0);
var c2Count = new AtomicInteger(0);
var c1 =
r1.run(
coroutine -> {
for (int i = 0; i < 5; i++) {
coroutine.yield();
c1Count.incrementAndGet();
}
})
.named("C1");
var c2 =
r2.run(
coroutine -> {
for (int i = 0; i < 10; i++) {
coroutine.yield();
c2Count.incrementAndGet();
}
})
.named("C2");
var parallel = new ParallelGroup("Parallel", List.of(c1, c2), List.of());
m_scheduler.schedule(parallel);
// First call to run() should schedule and start the commands
m_scheduler.run();
assertTrue(m_scheduler.isRunning(parallel));
assertTrue(m_scheduler.isRunning(c1));
assertTrue(m_scheduler.isRunning(c2));
// Next call to run() should start them
for (int i = 1; i < 5; i++) {
m_scheduler.run();
assertTrue(m_scheduler.isRunning(c1));
assertTrue(m_scheduler.isRunning(c2));
assertEquals(i, c1Count.get());
assertEquals(i, c2Count.get());
}
// c1 should finish after 5 iterations; c2 should continue for another 5
for (int i = 5; i < 10; i++) {
m_scheduler.run();
assertFalse(m_scheduler.isRunning(c1));
assertTrue(m_scheduler.isRunning(c2));
assertEquals(5, c1Count.get());
assertEquals(i, c2Count.get());
}
// one final run() should unschedule the c2 command and end the group
assertTrue(m_scheduler.isRunning(parallel));
m_scheduler.run();
assertFalse(m_scheduler.isRunning(c1));
assertFalse(m_scheduler.isRunning(c2));
assertFalse(m_scheduler.isRunning(parallel));
// and final counts should be 5 and 10
assertEquals(5, c1Count.get());
assertEquals(10, c2Count.get());
}
@Test
void race() {
var r1 = new Mechanism("R1", m_scheduler);
var r2 = new Mechanism("R2", m_scheduler);
var c1Count = new AtomicInteger(0);
var c2Count = new AtomicInteger(0);
var c1 =
r1.run(
coroutine -> {
for (int i = 0; i < 5; i++) {
coroutine.yield();
c1Count.incrementAndGet();
}
})
.named("C1");
var c2 =
r2.run(
coroutine -> {
for (int i = 0; i < 10; i++) {
coroutine.yield();
c2Count.incrementAndGet();
}
})
.named("C2");
var race = new ParallelGroup("Race", List.of(), List.of(c1, c2));
m_scheduler.schedule(race);
// First call to run() should schedule the commands
m_scheduler.run();
assertTrue(m_scheduler.isRunning(race));
assertTrue(m_scheduler.isRunning(c1));
assertTrue(m_scheduler.isRunning(c2));
for (int i = 1; i < 5; i++) {
m_scheduler.run();
assertTrue(m_scheduler.isRunning(c1));
assertTrue(m_scheduler.isRunning(c2));
assertEquals(i, c1Count.get());
assertEquals(i, c2Count.get());
}
m_scheduler.run(); // complete c1
assertFalse(m_scheduler.isRunning(race));
assertFalse(m_scheduler.isRunning(c1));
assertFalse(m_scheduler.isRunning(c2));
// and final counts should be 5 and 5
assertEquals(5, c1Count.get());
assertEquals(5, c2Count.get());
}
@Test
void nested() {
var mechanism = new Mechanism("mechanism", m_scheduler);
var count = new AtomicInteger(0);
var command =
mechanism
.run(
coroutine -> {
for (int i = 0; i < 5; i++) {
coroutine.yield();
count.incrementAndGet();
}
})
.named("Command");
var inner = new ParallelGroup("Inner", Set.of(command), Set.of());
var outer = new ParallelGroup("Outer", Set.of(), Set.of(inner));
// Scheduling: Outer group should be on deck
m_scheduler.schedule(outer);
assertTrue(m_scheduler.isScheduled(outer));
assertFalse(m_scheduler.isScheduledOrRunning(inner));
assertFalse(m_scheduler.isScheduledOrRunning(command));
// First run: Inner group and command should both be scheduled and running
m_scheduler.run();
assertTrue(m_scheduler.isRunning(outer), "Outer group should be running");
assertTrue(m_scheduler.isRunning(inner), "Inner group should be running");
assertTrue(m_scheduler.isRunning(command), "Command should be running");
assertEquals(0, count.get());
// Runs 2 through 5: Outer and inner should both be running while the command runs
for (int i = 1; i < 5; i++) {
m_scheduler.run();
assertTrue(m_scheduler.isRunning(outer), "Outer group should be running");
assertTrue(m_scheduler.isRunning(inner), "Inner group should be running");
assertTrue(m_scheduler.isRunning(command), "Command should be running (" + i + ")");
assertEquals(i, count.get());
}
// Run 6: Command should have completed naturally
m_scheduler.run();
assertFalse(m_scheduler.isRunning(outer), "Outer group should be running");
assertFalse(m_scheduler.isRunning(inner), "Inner group should be running");
assertFalse(m_scheduler.isRunning(command), "Command should have completed");
}
@Test
void automaticNameRace() {
var a = Command.noRequirements().executing(coroutine -> {}).named("A");
var b = Command.noRequirements().executing(coroutine -> {}).named("B");
var c = Command.noRequirements().executing(coroutine -> {}).named("C");
var group = new ParallelGroupBuilder().optional(a, b, c).withAutomaticName();
assertEquals("(A | B | C)", group.name());
}
@Test
void automaticNameAll() {
var a = Command.noRequirements().executing(coroutine -> {}).named("A");
var b = Command.noRequirements().executing(coroutine -> {}).named("B");
var c = Command.noRequirements().executing(coroutine -> {}).named("C");
var group = new ParallelGroupBuilder().requiring(a, b, c).withAutomaticName();
assertEquals("(A & B & C)", group.name());
}
@Test
void automaticNameDeadline() {
var a = Command.noRequirements().executing(coroutine -> {}).named("A");
var b = Command.noRequirements().executing(coroutine -> {}).named("B");
var c = Command.noRequirements().executing(coroutine -> {}).named("C");
var group = new ParallelGroupBuilder().requiring(a).optional(b, c).withAutomaticName();
assertEquals("[(A) * (B | C)]", group.name());
}
@Test
void inheritsRequirements() {
var mech1 = new Mechanism("Mech 1", m_scheduler);
var mech2 = new Mechanism("Mech 2", m_scheduler);
var command1 = mech1.run(Coroutine::park).named("Command 1");
var command2 = mech2.run(Coroutine::park).named("Command 2");
var group = new ParallelGroup("Group", Set.of(command1, command2), Set.of());
assertEquals(Set.of(mech1, mech2), group.requirements(), "Requirements were not inherited");
}
@Test
void inheritsPriority() {
var mech1 = new Mechanism("Mech 1", m_scheduler);
var mech2 = new Mechanism("Mech 2", m_scheduler);
var command1 = mech1.run(Coroutine::park).withPriority(100).named("Command 1");
var command2 = mech2.run(Coroutine::park).withPriority(200).named("Command 2");
var group = new ParallelGroup("Group", Set.of(command1, command2), Set.of());
assertEquals(200, group.priority(), "Priority was not inherited");
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import java.util.Set;
record PriorityCommand(int priority, Mechanism... subsystems) implements Command {
@Override
public void run(Coroutine coroutine) {
coroutine.park();
}
@Override
public Set<Mechanism> requirements() {
return Set.of(subsystems);
}
@Override
public String name() {
return toString();
}
@Override
public String toString() {
return "PriorityCommand[priority=" + priority + ", subsystems=" + Set.of(subsystems) + "]";
}
}

View File

@@ -0,0 +1,369 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class SchedulerCancellationTests extends CommandTestBase {
@Test
void cancelOnInterruptDoesNotResume() {
var count = new AtomicInteger(0);
var mechanism = new Mechanism("mechanism", m_scheduler);
var interrupter =
Command.requiring(mechanism)
.executing(coroutine -> {})
.withPriority(2)
.named("Interrupter");
var canceledCommand =
Command.requiring(mechanism)
.executing(
coroutine -> {
count.set(1);
coroutine.yield();
count.set(2);
})
.withPriority(1)
.named("Cancel By Default");
m_scheduler.schedule(canceledCommand);
m_scheduler.run();
m_scheduler.schedule(interrupter);
m_scheduler.run();
assertEquals(1, count.get()); // the second "set" call should not have run
}
@Test
void defaultCommandResumesAfterInterruption() {
var count = new AtomicInteger(0);
var mechanism = new Mechanism("mechanism", m_scheduler);
var defaultCmd =
mechanism
.run(
coroutine -> {
while (true) {
count.incrementAndGet();
coroutine.yield();
}
})
.withPriority(-1)
.named("Default Command");
final var newerCmd = mechanism.run(coroutine -> {}).named("Newer Command");
mechanism.setDefaultCommand(defaultCmd);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(defaultCmd), "Default command should be running");
m_scheduler.schedule(newerCmd);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(defaultCmd), "Default command should have been interrupted");
assertEquals(1, count.get(), "Default command should have run once");
m_scheduler.run();
assertTrue(m_scheduler.isRunning(defaultCmd), "Default command should have resumed");
assertEquals(2, count.get());
}
@Test
void cancelsEvictsOnDeck() {
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
m_scheduler.schedule(command);
m_scheduler.cancel(command);
assertFalse(m_scheduler.isScheduledOrRunning(command));
}
@Test
void commandCancelingSelf() {
var ranAfterCancel = new AtomicBoolean(false);
var commandRef = new AtomicReference<Command>(null);
var command =
Command.noRequirements()
.executing(
co -> {
co.scheduler().cancel(commandRef.get());
ranAfterCancel.set(true);
})
.named("Command");
commandRef.set(command);
m_scheduler.schedule(command);
var error = assertThrows(IllegalArgumentException.class, () -> m_scheduler.run());
assertEquals("Command `Command` is mounted and cannot be canceled", error.getMessage());
assertFalse(ranAfterCancel.get(), "Command should have stopped after encountering an error");
assertFalse(
m_scheduler.isScheduledOrRunning(command),
"Command should have been removed from the scheduler");
}
@Test
void cancelAllEvictsOnDeck() {
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
m_scheduler.schedule(command);
m_scheduler.cancelAll();
assertFalse(m_scheduler.isScheduledOrRunning(command));
}
@Test
void cancelAllCancelsAll() {
var commands = new ArrayList<Command>(10);
for (int i = 1; i <= 10; i++) {
commands.add(Command.noRequirements().executing(Coroutine::yield).named("Command " + i));
}
commands.forEach(m_scheduler::schedule);
m_scheduler.run();
m_scheduler.cancelAll();
for (Command command : commands) {
if (m_scheduler.isRunning(command)) {
fail(command.name() + " was not canceled by cancelAll()");
}
}
}
@Test
void cancelAllCallsOnCancelHookForRunningCommands() {
AtomicBoolean ranHook = new AtomicBoolean(false);
var command =
Command.noRequirements()
.executing(Coroutine::park)
.whenCanceled(() -> ranHook.set(true))
.named("Command");
m_scheduler.schedule(command);
m_scheduler.run();
m_scheduler.cancelAll();
assertTrue(ranHook.get(), "onCancel hook was not called");
}
@Test
void cancelAllDoesNotCallOnCancelHookForQueuedCommands() {
AtomicBoolean ranHook = new AtomicBoolean(false);
var command =
Command.noRequirements()
.executing(Coroutine::park)
.whenCanceled(() -> ranHook.set(true))
.named("Command");
m_scheduler.schedule(command);
// no call to run before cancelAll()
m_scheduler.cancelAll();
assertFalse(ranHook.get(), "onCancel hook was not called");
}
@Test
void cancelAllStartsDefaults() {
var mechanisms = new ArrayList<Mechanism>(10);
for (int i = 1; i <= 10; i++) {
mechanisms.add(new Mechanism("System " + i, m_scheduler));
}
var command = Command.requiring(mechanisms).executing(Coroutine::yield).named("Big Command");
// Scheduling the command should evict the on-deck default commands
m_scheduler.schedule(command);
// Then running should get it into the set of running commands
m_scheduler.run();
// Canceling should clear out the set of running commands
m_scheduler.cancelAll();
// Then ticking the scheduler once to fully remove the command and schedule the defaults
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command), "Command was not canceled by cancelAll()");
for (var mechanism : mechanisms) {
var runningCommands = m_scheduler.getRunningCommandsFor(mechanism);
assertEquals(
1,
runningCommands.size(),
"mechanism " + mechanism + " should have exactly one running command");
assertEquals(
mechanism.getDefaultCommand(),
runningCommands.getFirst(),
"mechanism " + mechanism + " is not running the default command");
}
}
@Test
void cancelDeeplyNestedCompositions() {
Command root =
Command.noRequirements()
.executing(
co -> {
co.await(
Command.noRequirements()
.executing(
co2 -> {
co2.await(
Command.noRequirements()
.executing(
co3 -> {
co3.await(
Command.noRequirements()
.executing(Coroutine::park)
.named("Park"));
})
.named("C3"));
})
.named("C2"));
})
.named("Root");
m_scheduler.schedule(root);
m_scheduler.run();
assertEquals(4, m_scheduler.getRunningCommands().size());
m_scheduler.cancel(root);
assertEquals(0, m_scheduler.getRunningCommands().size());
}
@Test
void compositionsDoNotSelfCancel() {
var mech = new Mechanism("The mechanism", m_scheduler);
var group =
mech.run(
co -> {
co.await(
mech.run(
co2 -> {
co2.await(
mech.run(
co3 -> {
co3.await(mech.run(Coroutine::park).named("Park"));
})
.named("C3"));
})
.named("C2"));
})
.named("Group");
m_scheduler.schedule(group);
m_scheduler.run();
assertEquals(4, m_scheduler.getRunningCommands().size());
assertTrue(m_scheduler.isRunning(group));
}
@Test
void compositionsDoNotCancelParent() {
var mech = new Mechanism("The mechanism", m_scheduler);
var group =
mech.run(
co -> {
co.fork(mech.run(Coroutine::park).named("First Child"));
co.fork(mech.run(Coroutine::park).named("Second Child"));
co.park();
})
.named("Group");
m_scheduler.schedule(group);
m_scheduler.run();
// second child interrupts first child
assertEquals(
List.of("Group", "Second Child"),
m_scheduler.getRunningCommands().stream().map(Command::name).toList());
}
@Test
void doesNotRunOnCancelWhenInterruptingOnDeck() {
var ran = new AtomicBoolean(false);
var mechanism = new Mechanism("The mechanism", m_scheduler);
var cmd = mechanism.run(Coroutine::yield).whenCanceled(() -> ran.set(true)).named("cmd");
var interrupter = mechanism.run(Coroutine::yield).named("Interrupter");
m_scheduler.schedule(cmd);
m_scheduler.schedule(interrupter);
m_scheduler.run();
assertFalse(ran.get(), "onCancel ran when it shouldn't have!");
}
@Test
void doesNotRunOnCancelWhenCancelingOnDeck() {
var ran = new AtomicBoolean(false);
var mechanism = new Mechanism("The mechanism", m_scheduler);
var cmd = mechanism.run(Coroutine::yield).whenCanceled(() -> ran.set(true)).named("cmd");
m_scheduler.schedule(cmd);
// canceling before calling .run()
m_scheduler.cancel(cmd);
m_scheduler.run();
assertFalse(ran.get(), "onCancel ran when it shouldn't have!");
}
@Test
void runsOnCancelWhenInterruptingCommand() {
var ran = new AtomicBoolean(false);
var mechanism = new Mechanism("The mechanism", m_scheduler);
var cmd = mechanism.run(Coroutine::park).whenCanceled(() -> ran.set(true)).named("cmd");
var interrupter = mechanism.run(Coroutine::park).named("Interrupter");
m_scheduler.schedule(cmd);
m_scheduler.run();
m_scheduler.schedule(interrupter);
m_scheduler.run();
assertTrue(ran.get(), "onCancel should have run!");
}
@Test
void doesNotRunOnCancelWhenCompleting() {
var ran = new AtomicBoolean(false);
var mechanism = new Mechanism("The mechanism", m_scheduler);
var cmd = mechanism.run(Coroutine::yield).whenCanceled(() -> ran.set(true)).named("cmd");
m_scheduler.schedule(cmd);
m_scheduler.run();
m_scheduler.run();
assertFalse(m_scheduler.isScheduledOrRunning(cmd));
assertFalse(ran.get(), "onCancel ran when it shouldn't have!");
}
@Test
void runsOnCancelWhenCanceling() {
var ran = new AtomicBoolean(false);
var mechanism = new Mechanism("The mechanism", m_scheduler);
var cmd = mechanism.run(Coroutine::yield).whenCanceled(() -> ran.set(true)).named("cmd");
m_scheduler.schedule(cmd);
m_scheduler.run();
m_scheduler.cancel(cmd);
assertTrue(ran.get(), "onCancel should have run!");
}
@Test
void runsOnCancelWhenCancelingParent() {
var ran = new AtomicBoolean(false);
var mechanism = new Mechanism("The mechanism", m_scheduler);
var cmd = mechanism.run(Coroutine::yield).whenCanceled(() -> ran.set(true)).named("cmd");
var group = new SequentialGroup("Seq", Collections.singletonList(cmd));
m_scheduler.schedule(group);
m_scheduler.run();
m_scheduler.cancel(group);
assertTrue(ran.get(), "onCancel should have run!");
}
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
class SchedulerConflictTests extends CommandTestBase {
@Test
void compositionsCannotAwaitConflictingCommands() {
var mech = new Mechanism("The Mechanism", m_scheduler);
var group =
Command.noRequirements()
.executing(
co -> {
co.awaitAll(
mech.run(Coroutine::park).named("First"),
mech.run(Coroutine::park).named("Second"));
})
.named("Group");
m_scheduler.schedule(group);
// Running should attempt to schedule multiple conflicting commands
var exception = assertThrows(IllegalArgumentException.class, m_scheduler::run);
assertEquals(
"Commands running in parallel cannot share requirements: "
+ "First and Second both require The Mechanism",
exception.getMessage());
}
@Test
void innerCommandMayInterruptOtherInnerCommand() {
var mechanism = new Mechanism("The mechanism", m_scheduler);
var firstRan = new AtomicBoolean(false);
var secondRan = new AtomicBoolean(false);
var first =
mechanism
.run(
c -> {
firstRan.set(true);
c.park();
})
.named("First");
var second =
mechanism
.run(
c -> {
secondRan.set(true);
c.park();
})
.named("Second");
var group =
Command.noRequirements()
.executing(
co -> {
co.fork(first);
co.fork(second);
co.park();
})
.named("Group");
m_scheduler.schedule(group);
m_scheduler.run();
assertTrue(firstRan.get(), "First child should have run to a yield point");
assertTrue(secondRan.get(), "Second child should have run to a yield point");
assertFalse(
m_scheduler.isScheduledOrRunning(first), "First child should have been interrupted");
assertTrue(m_scheduler.isRunning(second), "Second child should still be running");
assertTrue(m_scheduler.isRunning(group), "Group should still be running");
}
@Test
void nestedOneShotCompositionsAllRunInOneCycle() {
var runs = new AtomicInteger(0);
Supplier<Command> makeOneShot =
() -> Command.noRequirements().executing(_c -> runs.incrementAndGet()).named("One Shot");
var command =
Command.noRequirements()
.executing(
co -> {
co.fork(makeOneShot.get());
co.fork(makeOneShot.get());
co.fork(
Command.noRequirements()
.executing(inner -> inner.fork(makeOneShot.get()))
.named("Inner"));
co.fork(
Command.noRequirements()
.executing(
co2 -> {
co2.fork(makeOneShot.get());
co2.fork(
Command.noRequirements()
.executing(
co3 -> {
co3.fork(makeOneShot.get());
})
.named("3"));
})
.named("2"));
})
.named("Command");
m_scheduler.schedule(command);
m_scheduler.run();
assertEquals(5, runs.get(), "All oneshot commands should have run");
assertFalse(m_scheduler.isRunning(command), "Command should have exited after one cycle");
}
@Test
void childConflictsWithHigherPriorityTopLevel() {
var mechanism = new Mechanism("mechanism", m_scheduler);
var top = mechanism.run(Coroutine::park).withPriority(10).named("Top");
// Child conflicts with and is lower priority than the Top command
// It should not be scheduled, and the parent command should exit immediately
var child = mechanism.run(Coroutine::park).named("Child");
var parent = Command.noRequirements().executing(co -> co.await(child)).named("Parent");
m_scheduler.schedule(top);
m_scheduler.schedule(parent);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(top), "Top command should not have been interrupted");
assertFalse(m_scheduler.isRunning(child), "Conflicting child should not have run");
assertFalse(m_scheduler.isRunning(parent), "Parent of conflicting child should have exited");
}
@Test
void childConflictsWithLowerPriorityTopLevel() {
var mechanism = new Mechanism("mechanism", m_scheduler);
var top = mechanism.run(Coroutine::park).withPriority(-10).named("Top");
// Child conflicts with and is higher priority than the Top command
// It should be scheduled, and the top command should be interrupted
var child = mechanism.run(Coroutine::park).named("Child");
var parent = Command.noRequirements().executing(co -> co.await(child)).named("Parent");
m_scheduler.schedule(top);
m_scheduler.schedule(parent);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(top), "Top command should have been interrupted");
assertTrue(m_scheduler.isRunning(child), "Conflicting child should be running");
assertTrue(m_scheduler.isRunning(parent), "Parent of conflicting child should be running");
}
}

View File

@@ -0,0 +1,208 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
class SchedulerErrorHandlingTests extends CommandTestBase {
@Test
void errorDetection() {
var mechanism = new Mechanism("X", m_scheduler);
var command =
mechanism
.run(
coroutine -> {
throw new RuntimeException("The exception");
})
.named("Bad Behavior");
new Trigger(m_scheduler, () -> true).onTrue(command);
var e = assertThrows(RuntimeException.class, m_scheduler::run);
assertEquals("The exception", e.getMessage());
assertEquals(
"org.wpilib.commands3.SchedulerErrorHandlingTests", e.getStackTrace()[0].getClassName());
assertEquals("lambda$errorDetection$0", e.getStackTrace()[0].getMethodName());
assertEquals("=== Command Binding Trace ===", e.getStackTrace()[2].getClassName());
assertEquals(getClass().getName(), e.getStackTrace()[3].getClassName());
assertEquals("errorDetection", e.getStackTrace()[3].getMethodName());
}
@Test
void nestedErrorDetection() {
var command =
Command.noRequirements()
.executing(
co -> {
co.await(
Command.noRequirements()
.executing(
c2 -> {
new Trigger(m_scheduler, () -> true)
.onTrue(
Command.noRequirements()
.executing(
c3 -> {
// Throws IndexOutOfBoundsException
var unused = new ArrayList<>(0).get(-1);
})
.named("Throws IndexOutOfBounds"));
c2.park();
})
.named("Schedules With Trigger"));
})
.named("Schedules Directly");
m_scheduler.schedule(command);
// The first run sets up the trigger, but does not fire
// The second run will fire the trigger and cause the inner command to run and throw
m_scheduler.run();
var e = assertThrows(IndexOutOfBoundsException.class, m_scheduler::run);
StackTraceElement[] stackTrace = e.getStackTrace();
assertEquals("Index -1 out of bounds for length 0", e.getMessage());
int nestedIndex = 0;
for (; nestedIndex < stackTrace.length; nestedIndex++) {
if (stackTrace[nestedIndex].getClassName().equals(getClass().getName())) {
break;
}
}
// user code trace for the scheduler run invocation (to `scheduler.run()` in the try block)
assertEquals("lambda$nestedErrorDetection$3", stackTrace[nestedIndex].getMethodName());
assertEquals("assertThrows", stackTrace[nestedIndex + 1].getMethodName());
// user code trace for where the command was scheduled (the `.onTrue()` line)
assertEquals("=== Command Binding Trace ===", stackTrace[nestedIndex + 2].getClassName());
assertEquals("lambda$nestedErrorDetection$4", stackTrace[nestedIndex + 3].getMethodName());
assertEquals("lambda$nestedErrorDetection$5", stackTrace[nestedIndex + 4].getMethodName());
assertEquals("nestedErrorDetection", stackTrace[nestedIndex + 5].getMethodName());
}
@Test
void commandEncounteringErrorCancelsChildren() {
var child = Command.noRequirements().executing(Coroutine::park).named("Child 1");
var command =
Command.noRequirements()
.executing(
co -> {
co.fork(child);
throw new RuntimeException("The exception");
})
.named("Bad Behavior");
m_scheduler.schedule(command);
assertThrows(RuntimeException.class, m_scheduler::run);
assertFalse(
m_scheduler.isScheduledOrRunning(command),
"Command should have been removed from the scheduler");
assertFalse(
m_scheduler.isScheduledOrRunning(child),
"Child should have been removed from the scheduler");
}
@Test
void childCommandEncounteringErrorCancelsParent() {
var child =
Command.noRequirements()
.executing(
co -> {
throw new RuntimeException("The exception"); // note: bubbles up to the parent
})
.named("Child 1");
var command =
Command.noRequirements()
.executing(
co -> {
co.await(child);
co.park(); // pretend other things would happen after the child
})
.named("Parent");
m_scheduler.schedule(command);
assertThrows(RuntimeException.class, m_scheduler::run);
assertFalse(
m_scheduler.isRunning(command),
"Parent command should have been removed from the scheduler");
assertFalse(m_scheduler.isRunning(child), "Child should have been removed from the scheduler");
}
@Test
@SuppressWarnings("PMD.CompareObjectsWithEquals")
void childCommandEncounteringErrorAfterRemountCancelsParent() {
var child =
Command.noRequirements()
.executing(
co -> {
co.yield();
throw new RuntimeException("The exception"); // does not bubble up to the parent
})
.named("Child 1");
var command =
Command.noRequirements()
.executing(
co -> {
co.await(child);
co.park(); // pretend other things would happen after the child
})
.named("Parent");
m_scheduler.schedule(command);
// first run schedules the child and adds it to the running set
m_scheduler.run();
// second run encounters the error in the child
final var error = assertThrows(RuntimeException.class, m_scheduler::run);
assertFalse(
m_scheduler.isRunning(command),
"Parent command should have been removed from the scheduler");
assertFalse(m_scheduler.isRunning(child), "Child should have been removed from the scheduler");
// Full event history
assertEquals(9, m_events.size());
assertTrue(
m_events.get(0) instanceof SchedulerEvent.Scheduled s && s.command() == command,
"First event should be parent scheduled");
assertTrue(
m_events.get(1) instanceof SchedulerEvent.Mounted m && m.command() == command,
"Second event should be parent mounted");
assertTrue(
m_events.get(2) instanceof SchedulerEvent.Scheduled s && s.command() == child,
"Third event should be child scheduled");
assertTrue(
m_events.get(3) instanceof SchedulerEvent.Mounted m && m.command() == child,
"Fourth event should be child mounted");
assertTrue(
m_events.get(4) instanceof SchedulerEvent.Yielded y && y.command() == child,
"Fifth event should be child yielded");
assertTrue(
m_events.get(5) instanceof SchedulerEvent.Yielded y && y.command() == command,
"Sixth event should be parent yielded");
assertTrue(
m_events.get(6) instanceof SchedulerEvent.Mounted m && m.command() == child,
"Seventh event should be child remounted");
assertTrue(
m_events.get(7) instanceof SchedulerEvent.CompletedWithError c
&& c.command() == child
&& c.error() == error,
"Eighth event should be child completed with error");
assertTrue(
m_events.get(8) instanceof SchedulerEvent.Canceled c && c.command() == command,
"Ninth event should be parent canceled");
}
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class SchedulerPriorityLevelTests extends CommandTestBase {
@Test
void higherPriorityCancels() {
final var subsystem = new Mechanism("Subsystem", m_scheduler);
final var lower = new PriorityCommand(-1000, subsystem);
final var higher = new PriorityCommand(+1000, subsystem);
m_scheduler.schedule(lower);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(lower));
m_scheduler.schedule(higher);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(higher));
assertFalse(m_scheduler.isRunning(lower));
}
@Test
void lowerPriorityDoesNotCancel() {
final var subsystem = new Mechanism("Subsystem", m_scheduler);
final var lower = new PriorityCommand(-1000, subsystem);
final var higher = new PriorityCommand(+1000, subsystem);
m_scheduler.schedule(higher);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(higher));
m_scheduler.schedule(lower);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(higher), "Higher priority command should still be running");
assertFalse(
m_scheduler.isScheduledOrRunning(lower), "Lower priority command should not be running");
}
@Test
void samePriorityCancels() {
final var subsystem = new Mechanism("Subsystem", m_scheduler);
final var first = new PriorityCommand(512, subsystem);
final var second = new PriorityCommand(512, subsystem);
m_scheduler.schedule(first);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(first));
m_scheduler.schedule(second);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(second), "New command should be running");
assertFalse(m_scheduler.isRunning(first), "Old command should be canceled");
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class SchedulerSideloadFunctionTests extends CommandTestBase {
@Test
void sideloadThrowingException() {
m_scheduler.sideload(
co -> {
throw new RuntimeException("Bang!");
});
// An exception raised in a sideload function should bubble up
assertEquals(
"Bang!", assertThrowsExactly(RuntimeException.class, m_scheduler::run).getMessage());
}
@Test
void periodicSideload() {
AtomicInteger count = new AtomicInteger(0);
m_scheduler.addPeriodic(count::incrementAndGet);
assertEquals(0, count.get());
m_scheduler.run();
assertEquals(1, count.get());
m_scheduler.run();
assertEquals(2, count.get());
}
@Test
void sideloadSchedulingCommand() {
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
// one-shot sideload forks a command and immediately exits
m_scheduler.sideload(co -> co.fork(command));
m_scheduler.run();
assertTrue(
m_scheduler.isRunning(command), "command should have started and outlasted the sideload");
}
@Test
void childCommandEscapesViaSideload() {
var child = Command.noRequirements().executing(Coroutine::park).named("Child");
var parent =
Command.noRequirements()
.executing(
parentCoroutine -> {
m_scheduler.sideload(sidelodCoroutine -> sidelodCoroutine.fork(child));
})
.named("Parent");
m_scheduler.schedule(parent);
m_scheduler.run();
assertFalse(m_scheduler.isScheduledOrRunning(parent), "parent should have exited");
assertFalse(
m_scheduler.isScheduledOrRunning(child),
"the sideload to schedule the child should not have run yet");
m_scheduler.run();
assertTrue(m_scheduler.isRunning(child), "child should have started running");
}
@Test
void sideloadCancelingCommand() {
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
m_scheduler.schedule(command);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command), "command should have started");
m_scheduler.sideload(co -> m_scheduler.cancel(command));
assertTrue(m_scheduler.isRunning(command), "sideload should not have run yet");
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command), "sideload should have canceled the command");
}
@Test
void sideloadAffectsStateForTriggerInSameCycle() {
AtomicBoolean signal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, signal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.onTrue(command);
m_scheduler.sideload(co -> signal.set(true));
m_scheduler.run();
assertTrue(signal.get(), "Sideload should have run and set the signal");
assertTrue(m_scheduler.isRunning(command), "Command should have started");
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class SchedulerTelemetryTests extends CommandTestBase {
@Test
void protobuf() {
var mech = new Mechanism("The mechanism", m_scheduler);
var parkCommand = mech.run(Coroutine::park).named("Park");
var c3Command = mech.run(co -> co.await(parkCommand)).named("C3");
var c2Command = mech.run(co -> co.await(c3Command)).named("C2");
var group = mech.run(co -> co.await(c2Command)).named("Group");
m_scheduler.schedule(group);
m_scheduler.run();
var scheduledCommand1 = Command.noRequirements().executing(Coroutine::park).named("Command 1");
var scheduledCommand2 = Command.noRequirements().executing(Coroutine::park).named("Command 2");
m_scheduler.schedule(scheduledCommand1);
m_scheduler.schedule(scheduledCommand2);
var message = Scheduler.proto.createMessage();
Scheduler.proto.pack(message, m_scheduler);
var messageJson = message.toString();
assertEquals(
"""
{
"lastTimeMs": %s,
"queuedCommands": [{
"priority": 0,
"id": %s,
"name": "Command 1",
"requirements": []
}, {
"priority": 0,
"id": %s,
"name": "Command 2",
"requirements": []
}],
"runningCommands": [{
"lastTimeMs": %s,
"totalTimeMs": %s,
"priority": 0,
"id": %s,
"name": "Group",
"requirements": [{
"name": "The mechanism"
}]
}, {
"lastTimeMs": %s,
"totalTimeMs": %s,
"priority": 0,
"id": %s,
"parentId": %s,
"name": "C2",
"requirements": [{
"name": "The mechanism"
}]
}, {
"lastTimeMs": %s,
"totalTimeMs": %s,
"priority": 0,
"id": %s,
"parentId": %s,
"name": "C3",
"requirements": [{
"name": "The mechanism"
}]
}, {
"lastTimeMs": %s,
"totalTimeMs": %s,
"priority": 0,
"id": %s,
"parentId": %s,
"name": "Park",
"requirements": [{
"name": "The mechanism"
}]
}]
}"""
.formatted(
// Scheduler data
m_scheduler.lastRuntimeMs(),
// On deck commands
m_scheduler.runId(scheduledCommand1),
m_scheduler.runId(scheduledCommand2),
// Running commands
m_scheduler.lastCommandRuntimeMs(group),
m_scheduler.totalRuntimeMs(group),
m_scheduler.runId(group), // id
// top-level command, no parent ID
m_scheduler.lastCommandRuntimeMs(c2Command),
m_scheduler.totalRuntimeMs(c2Command),
m_scheduler.runId(c2Command), // id
m_scheduler.runId(group), // parent
m_scheduler.lastCommandRuntimeMs(c3Command),
m_scheduler.totalRuntimeMs(c3Command),
m_scheduler.runId(c3Command), // id
m_scheduler.runId(c2Command), // parent
m_scheduler.lastCommandRuntimeMs(parkCommand),
m_scheduler.totalRuntimeMs(parkCommand),
m_scheduler.runId(parkCommand), // id
m_scheduler.runId(c3Command) // parent
),
messageJson);
}
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
class SchedulerTest extends CommandTestBase {
@Test
void basic() {
var enabled = new AtomicBoolean(false);
var ran = new AtomicBoolean(false);
var command =
Command.noRequirements()
.executing(
coroutine -> {
do {
coroutine.yield();
} while (!enabled.get());
ran.set(true);
})
.named("Basic Command");
m_scheduler.schedule(command);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command), "Command should be running after being scheduled");
enabled.set(true);
m_scheduler.run();
if (m_scheduler.isRunning(command)) {
fail("Command should no longer be running after awaiting its completion");
}
assertTrue(ran.get());
}
@Test
@SuppressWarnings("PMD.ImmutableField") // PMD bugs
void atomicity() {
var mechanism =
new Mechanism("X", m_scheduler) {
int m_x = 0;
};
// Launch 100 commands that each call `x++` 500 times.
// If commands run on different threads, the lack of atomic
// operations or locks will mean the final number will be
// less than the expected 50,000
int numCommands = 100;
int iterations = 500;
for (int cmdCount = 0; cmdCount < numCommands; cmdCount++) {
var command =
Command.noRequirements()
.executing(
coroutine -> {
for (int i = 0; i < iterations; i++) {
mechanism.m_x++;
coroutine.yield();
}
})
.named("CountCommand[" + cmdCount + "]");
m_scheduler.schedule(command);
}
for (int i = 0; i < iterations; i++) {
m_scheduler.run();
}
assertEquals(numCommands * iterations, mechanism.m_x);
}
@Test
@SuppressWarnings("PMD.ImmutableField") // PMD bugs
void runMechanism() {
var example =
new Mechanism("Counting", m_scheduler) {
int m_x = 0;
};
Command countToTen =
example
.run(
coroutine -> {
example.m_x = 0;
for (int i = 0; i < 10; i++) {
coroutine.yield();
example.m_x++;
}
})
.named("Count To Ten");
m_scheduler.schedule(countToTen);
for (int i = 0; i < 10; i++) {
m_scheduler.run();
}
m_scheduler.run();
assertEquals(10, example.m_x);
}
@Test
void compositionsDoNotNeedRequirements() {
var m1 = new Mechanism("M1", m_scheduler);
var m2 = new Mechanism("m2", m_scheduler);
// the group has no requirements, but can schedule child commands that do
var group =
Command.noRequirements()
.executing(
co -> {
co.awaitAll(
m1.run(Coroutine::park).named("M1 Command"),
m2.run(Coroutine::park).named("M2 Command"));
})
.named("Composition");
m_scheduler.schedule(group);
m_scheduler.run(); // start m1 and m2 commands
assertEquals(3, m_scheduler.getRunningCommands().size());
}
@Test
void nestedMechanisms() {
var superstructure =
new Mechanism("Superstructure", m_scheduler) {
private final Mechanism m_elevator = new Mechanism("Elevator", m_scheduler);
private final Mechanism m_arm = new Mechanism("Arm", m_scheduler);
public Command superCommand() {
return run(co -> {
co.await(m_elevator.run(Coroutine::park).named("Elevator Subcommand"));
co.await(m_arm.run(Coroutine::park).named("Arm Subcommand"));
})
.named("Super Command");
}
};
m_scheduler.schedule(superstructure.superCommand());
m_scheduler.run();
assertEquals(
List.of(superstructure.m_arm.getDefaultCommand()),
superstructure.m_arm.getRunningCommands(),
"Arm should only be running its default command");
// Scheduling something that requires an in-use inner mechanism cancels the outer command
m_scheduler.schedule(superstructure.m_elevator.run(Coroutine::park).named("Conflict"));
m_scheduler.run(); // schedules the default superstructure command
m_scheduler.run(); // starts running the default superstructure command
assertEquals(List.of(superstructure.getDefaultCommand()), superstructure.getRunningCommands());
}
}

View File

@@ -0,0 +1,244 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static edu.wpi.first.units.Units.Microseconds;
import static edu.wpi.first.units.Units.Milliseconds;
import static edu.wpi.first.units.Units.Seconds;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import edu.wpi.first.wpilibj.RobotController;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class SchedulerTimingTests extends CommandTestBase {
@Test
void commandAwaitingItself() {
// This command deadlocks on itself. It's calling yield() in an infinite loop, which is
// equivalent to calling Coroutine.park(). No deleterious side effects other than stalling
// the command
AtomicReference<Command> commandRef = new AtomicReference<>();
var command =
Command.noRequirements().executing(co -> co.await(commandRef.get())).named("Self Await");
commandRef.set(command);
m_scheduler.schedule(command);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command));
}
@Test
void commandDeadlock() {
AtomicReference<Command> parentRef = new AtomicReference<>();
AtomicReference<Command> childRef = new AtomicReference<>();
// Deadlock scenario:
// parent starts, schedules child, then waits for child to exit
// child starts, waits for parent to exit
//
// Each successive run sees parent mount, check for child, then yield.
// Then sees child mount, check for parent, then also yield.
// This is like two threads spinwaiting for the other to exit.
//
// Externally canceling child allows parent to continue
// Externally canceling parent cancels both
var parent = Command.noRequirements().executing(co -> co.await(childRef.get())).named("Parent");
var child = Command.noRequirements().executing(co -> co.await(parentRef.get())).named("Child");
parentRef.set(parent);
childRef.set(child);
m_scheduler.schedule(parent);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(parent));
assertTrue(m_scheduler.isRunning(child));
m_scheduler.run();
assertTrue(m_scheduler.isRunning(parent));
assertTrue(m_scheduler.isRunning(child));
m_scheduler.cancel(parent);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(parent));
assertFalse(m_scheduler.isRunning(child));
}
@Test
void delayedCommandDeadlock() {
AtomicReference<Command> ref1 = new AtomicReference<>();
AtomicReference<Command> ref2 = new AtomicReference<>();
AtomicBoolean command1CompletedNormally = new AtomicBoolean(false);
AtomicBoolean command2CompletedNormally = new AtomicBoolean(false);
// Deadlock scenario:
// command1 starts, waits for command2 to exit
// command2 starts, waits for command1 to exit
//
// Each successive run sees command1 mount, check for command2, then yield.
// Then sees command2 mount, check for command1, then also yield.
// This is like two threads spinwaiting for the other to exit.
//
// Externally canceling either command allows the other to exit
var command1 =
Command.noRequirements()
.executing(
co -> {
co.yield();
co.await(ref2.get());
command1CompletedNormally.set(true);
})
.named("Command 1");
var command2 =
Command.noRequirements()
.executing(
co -> {
co.yield();
co.await(ref1.get());
command2CompletedNormally.set(true);
})
.named("Command 2");
ref1.set(command1);
ref2.set(command2);
m_scheduler.schedule(command1);
m_scheduler.schedule(command2);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command1));
assertTrue(m_scheduler.isRunning(command2));
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command1));
assertTrue(m_scheduler.isRunning(command2));
m_scheduler.cancel(command2);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command1));
assertFalse(m_scheduler.isRunning(command2));
assertTrue(
command1CompletedNormally.get(),
"Command 1 should have completed normally after command 2 stopped");
assertFalse(
command2CompletedNormally.get(),
"Canceling command 2 should have stopped it before completing");
}
@Test
void forkedChildRunsOnce() {
AtomicInteger runCount = new AtomicInteger(0);
var inner =
Command.noRequirements()
.executing(
co -> {
runCount.incrementAndGet();
co.yield();
runCount.incrementAndGet();
co.yield();
})
.named("Inner");
var outer = Command.noRequirements().executing(co -> co.await(inner)).named("Outer");
m_scheduler.schedule(outer);
m_scheduler.run();
assertEquals(1, runCount.get());
}
@Test
void shortWaitWaitsOneLoop() {
AtomicLong time = new AtomicLong(0);
RobotController.setTimeSource(time::get);
AtomicBoolean completedWait = new AtomicBoolean(false);
var command =
Command.noRequirements()
.executing(
co -> {
co.wait(Milliseconds.of(1));
completedWait.set(true);
})
.named("Short Wait");
m_scheduler.schedule(command);
m_scheduler.run();
// wait 1 full second (much longer than the wait period)
time.set((long) Seconds.of(1).in(Microseconds));
m_scheduler.run();
assertTrue(
completedWait.get(),
"Command with a short wait should have completed if its duration has elapsed between runs");
}
@Test
void shortWaitWaitsOneLoopWithFastPeriod() {
AtomicLong time = new AtomicLong(0);
RobotController.setTimeSource(time::get);
AtomicBoolean completedWait = new AtomicBoolean(false);
var command =
Command.noRequirements()
.executing(
co -> {
co.wait(Milliseconds.of(1));
completedWait.set(true);
})
.named("Short Wait");
m_scheduler.schedule(command);
m_scheduler.run();
// move forward by half the wait period
time.set((long) Milliseconds.of(0.5).in(Microseconds));
m_scheduler.run();
assertFalse(completedWait.get(), "Command should still be waiting for 1 ms to elapse");
// move forward by the rest of the wait period
time.set((long) Milliseconds.of(1).in(Microseconds));
m_scheduler.run();
assertTrue(
completedWait.get(),
"Command with a short wait should have completed if its duration has elapsed between runs");
}
@Test
void awaitingExitsImmediatelyWithoutAOneLoopDelay() {
AtomicInteger innerRuns = new AtomicInteger(0);
var inner =
Command.noRequirements()
.executing(
co -> {
// executed immediately when forked
innerRuns.incrementAndGet();
co.yield();
// executed again on the next scheduler run, after the forking command runs
innerRuns.incrementAndGet();
})
.named("Inner");
var outer = Command.noRequirements().executing(co -> co.await(inner)).named("Outer");
m_scheduler.schedule(outer);
// First run: runs outer, forks inner, inner runs to its first yield, outer yields
m_scheduler.run();
assertTrue(m_scheduler.isRunning(inner));
assertTrue(m_scheduler.isRunning(outer));
assertEquals(1, innerRuns.get());
// Second run: runs inner to completion, runs outer, outer sees inner is complete and exits
// NOTE: If child commands ran AFTER their parents, then outer would not have exited here and
// would take another scheduler run to complete
m_scheduler.run();
assertFalse(m_scheduler.isRunning(inner));
assertFalse(m_scheduler.isRunning(outer));
assertEquals(2, innerRuns.get());
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
class SequentialGroupTest extends CommandTestBase {
@Test
void single() {
var command = Command.noRequirements().executing(Coroutine::yield).named("The Command");
var sequence = new SequentialGroup("The Sequence", List.of(command));
m_scheduler.schedule(sequence);
// First run - the composed command starts and yields; sequence yields
m_scheduler.run();
assertTrue(m_scheduler.isRunning(sequence));
assertTrue(m_scheduler.isRunning(command));
// Second run - the composed command completes; sequence sees its completion and exits
m_scheduler.run();
assertFalse(m_scheduler.isRunning(sequence));
assertFalse(m_scheduler.isRunning(command));
}
@Test
void twoCommands() {
var c1 = Command.noRequirements().executing(Coroutine::yield).named("C1");
var c2 = Command.noRequirements().executing(Coroutine::yield).named("C2");
var sequence = new SequentialGroup("C1 > C2", List.of(c1, c2));
m_scheduler.schedule(sequence);
// First run - c1 is scheduled and starts
m_scheduler.run();
assertTrue(m_scheduler.isRunning(sequence), "Sequence should be running");
assertTrue(m_scheduler.isRunning(c1), "Starting the sequence should start the first command");
assertFalse(
m_scheduler.isScheduledOrRunning(c2),
"The second command should still be pending completion of the first command");
// Second run - c1 completes, sequence sees it finish, schedules c2
m_scheduler.run();
assertTrue(m_scheduler.isRunning(sequence));
assertFalse(m_scheduler.isRunning(c1), "First command should have completed");
assertTrue(
m_scheduler.isScheduledOrRunning(c2), "Second command should not start in the same cycle");
// Third run - c2 completes, sequence sees it finish, exits
m_scheduler.run();
assertFalse(m_scheduler.isRunning(sequence));
assertFalse(m_scheduler.isRunning(c2), "Second command should have started");
}
@Test
void inheritsRequirements() {
var mech1 = new Mechanism("Mech 1", m_scheduler);
var mech2 = new Mechanism("Mech 2", m_scheduler);
var command1 = mech1.run(Coroutine::park).named("Command 1");
var command2 = mech2.run(Coroutine::park).named("Command 2");
var sequence = new SequentialGroup("Sequence", List.of(command1, command2));
assertEquals(Set.of(mech1, mech2), sequence.requirements(), "Requirements were not inherited");
}
@Test
void inheritsPriority() {
var mech1 = new Mechanism("Mech 1", m_scheduler);
var mech2 = new Mechanism("Mech 2", m_scheduler);
var command1 = mech1.run(Coroutine::park).withPriority(100).named("Command 1");
var command2 = mech2.run(Coroutine::park).withPriority(200).named("Command 2");
var sequence = new SequentialGroup("Sequence", List.of(command1, command2));
assertEquals(200, sequence.priority(), "Priority was not inherited");
}
}

View File

@@ -0,0 +1,254 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class StagedCommandBuilderTest {
private static final Runnable no_op = () -> {};
private Mechanism m_mech1;
private Mechanism m_mech2;
@BeforeEach
void setUp() {
Scheduler scheduler = Scheduler.createIndependentScheduler();
m_mech1 = new Mechanism("Mech 1", scheduler);
m_mech2 = new Mechanism("Mech 2", scheduler);
}
// The next two tests are to check that various forms of builder usage are able to compile.
@Test
void streamlined() {
Command command =
new StagedCommandBuilder()
.noRequirements()
.executing(Coroutine::park)
.until(() -> false)
.named("Name");
assertEquals("Name", command.name());
}
@Test
void allOptions() {
var mech = new Mechanism("Mech", Scheduler.createIndependentScheduler());
Command command =
new StagedCommandBuilder()
.noRequirements()
.requiring(mech)
.requiring(mech, mech)
.requiring(List.of(mech))
.executing(Coroutine::park)
.whenCanceled(no_op)
.until(() -> false)
.withPriority(10)
.named("Name");
assertEquals("Name", command.name());
}
@Test
void starting_noRequirements_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var ignored = builder.noRequirements().executing(c -> {}).named("cmd");
var err = assertThrows(IllegalStateException.class, builder::noRequirements);
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void starting_requiringVarargs_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var ignored = builder.noRequirements().executing(c -> {}).named("cmd");
var err = assertThrows(IllegalStateException.class, () -> builder.requiring(m_mech1, m_mech2));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void starting_requiringCollection_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var ignored = builder.noRequirements().executing(c -> {}).named("cmd");
var err =
assertThrows(
IllegalStateException.class, () -> builder.requiring(List.of(m_mech1, m_mech2)));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void requirements_requiringSingle_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var reqStage = builder.noRequirements();
var ignored = reqStage.executing(c -> {}).named("cmd");
var err = assertThrows(IllegalStateException.class, () -> reqStage.requiring(m_mech1));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void requirements_requiringVarargs_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var reqStage = builder.noRequirements();
var ignored = reqStage.executing(Coroutine::park).named("cmd");
var err = assertThrows(IllegalStateException.class, () -> reqStage.requiring(m_mech1, m_mech2));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void requirements_requiringCollection_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var reqStage = builder.noRequirements();
var ignored = reqStage.executing(Coroutine::park).named("cmd");
var err = assertThrows(IllegalStateException.class, () -> reqStage.requiring(List.of(m_mech1)));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void requirements_executing_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var reqStage = builder.noRequirements();
var ignored = reqStage.executing(c -> {}).named("cmd");
Consumer<Coroutine> impl = Coroutine::park;
var err = assertThrows(IllegalStateException.class, () -> reqStage.executing(impl));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void execution_whenCanceled_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var execStage = builder.noRequirements().executing(c -> {});
var ignored = execStage.named("cmd");
var err = assertThrows(IllegalStateException.class, () -> execStage.whenCanceled(() -> {}));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void execution_withPriority_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var execStage = builder.noRequirements().executing(c -> {});
var ignored = execStage.named("cmd");
var err = assertThrows(IllegalStateException.class, () -> execStage.withPriority(7));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void execution_until_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var execStage = builder.noRequirements().executing(c -> {});
var ignored = execStage.named("cmd");
BooleanSupplier endCondition = () -> true;
var err = assertThrows(IllegalStateException.class, () -> execStage.until(endCondition));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void execution_named_throwsAfterBuild() {
var builder = new StagedCommandBuilder();
var execStage = builder.noRequirements().executing(c -> {});
var ignored = execStage.named("cmd");
var err = assertThrows(IllegalStateException.class, () -> execStage.named("other"));
assertEquals("Command builders cannot be reused", err.getMessage());
}
@Test
void starting_requiringVarargs_nullFirstRequirement_throwsNPE() {
var builder = new StagedCommandBuilder();
assertThrows(NullPointerException.class, () -> builder.requiring(null, m_mech2));
}
@Test
void starting_requiringVarargs_nullArray_throwsNPE() {
var builder = new StagedCommandBuilder();
assertThrows(NullPointerException.class, () -> builder.requiring(m_mech1, (Mechanism[]) null));
}
@Test
void starting_requiringVarargs_nullInExtra_throwsNPE() {
var builder = new StagedCommandBuilder();
assertThrows(NullPointerException.class, () -> builder.requiring(m_mech1, m_mech2, null));
}
@Test
void starting_requiringCollection_nullCollection_throwsNPE() {
var builder = new StagedCommandBuilder();
assertThrows(NullPointerException.class, () -> builder.requiring((Collection<Mechanism>) null));
}
@Test
void starting_requiringCollection_nullElement_throwsNPE() {
var builder = new StagedCommandBuilder();
var listWithNull = Arrays.asList(m_mech1, null, m_mech2); // Arrays.asList allows nulls
assertThrows(NullPointerException.class, () -> builder.requiring(listWithNull));
}
@Test
void requirements_requiringSingle_null_throwsNPE() {
var req = new StagedCommandBuilder().noRequirements();
assertThrows(NullPointerException.class, () -> req.requiring((Mechanism) null));
}
@Test
void requirements_requiringVarargs_nullFirstRequirement_throwsNPE() {
var req = new StagedCommandBuilder().noRequirements();
assertThrows(NullPointerException.class, () -> req.requiring(null, m_mech2));
}
@Test
void requirements_requiringVarargs_nullArray_throwsNPE() {
var req = new StagedCommandBuilder().noRequirements();
assertThrows(NullPointerException.class, () -> req.requiring(m_mech1, (Mechanism[]) null));
}
@Test
void requirements_requiringVarargs_nullInExtra_throwsNPE() {
var req = new StagedCommandBuilder().noRequirements();
assertThrows(NullPointerException.class, () -> req.requiring(m_mech1, m_mech2, null));
}
@Test
void requirements_requiringCollection_nullCollection_throwsNPE() {
var req = new StagedCommandBuilder().noRequirements();
assertThrows(NullPointerException.class, () -> req.requiring((Collection<Mechanism>) null));
}
@Test
void requirements_requiringCollection_nullElement_throwsNPE() {
var req = new StagedCommandBuilder().noRequirements();
var listWithNull = Arrays.asList(m_mech1, null); // Arrays.asList allows nulls
assertThrows(NullPointerException.class, () -> req.requiring(listWithNull));
}
@Test
void requirements_executing_nullImpl_throwsNPE() {
var req = new StagedCommandBuilder().noRequirements();
assertThrows(NullPointerException.class, () -> req.executing(null));
}
@Test
void execution_named_nullName_throwsNPE() {
var exec = new StagedCommandBuilder().noRequirements().executing(c -> {});
assertThrows(NullPointerException.class, () -> exec.named(null));
}
}

View File

@@ -0,0 +1,247 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.commands3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
class TriggerTest extends CommandTestBase {
@Test
void onTrue() {
var signal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, signal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.onTrue(command);
signal.set(true);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command), "Command was not scheduled on rising edge");
signal.set(false);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command), "Command should still be running on falling edge");
}
@Test
void onFalse() {
var signal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, signal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.onFalse(command);
m_scheduler.run();
assertTrue(
m_scheduler.isRunning(command), "Command should be scheduled when signal starts low");
signal.set(true);
m_scheduler.run();
assertTrue(
m_scheduler.isRunning(command), "Command should still be running rising falling edge");
}
@Test
void whileTrue() {
var signal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, signal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.whileTrue(command);
signal.set(true);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command), "Command was not scheduled on rising edge");
signal.set(false);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command), "Command should be canceled on falling edge");
}
@Test
void whileFalse() {
var signal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, signal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.whileFalse(command);
m_scheduler.run();
assertTrue(
m_scheduler.isRunning(command), "Command should be scheduled when signal starts low");
signal.set(true);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command), "Command should be canceled on rising edge");
}
@Test
void toggleOnTrue() {
var signal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, signal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.toggleOnTrue(command);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command));
signal.set(true);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command));
signal.set(false);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command));
signal.set(true);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command));
}
@Test
void toggleOnFalse() {
var signal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, signal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.toggleOnFalse(command);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command));
signal.set(true);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command));
signal.set(false);
m_scheduler.run();
assertFalse(m_scheduler.isRunning(command));
}
@Test
void commandScoping() {
var innerRan = new AtomicBoolean(false);
var innerSignal = new AtomicBoolean(false);
var inner =
Command.noRequirements()
.executing(
co -> {
while (true) {
innerRan.set(true);
co.park();
}
})
.named("Inner");
var outer =
Command.noRequirements()
.executing(
co -> {
new Trigger(m_scheduler, innerSignal::get).onTrue(inner);
// If we yield, then the outer command exits and immediately cancels the
// on-deck inner command before it can run
co.park();
})
.named("Outer");
m_scheduler.schedule(outer);
m_scheduler.run();
assertFalse(innerRan.get(), "The bound command should not run before the signal is set");
innerSignal.set(true);
m_scheduler.run();
assertTrue(innerRan.get(), "The nested trigger should have run the bound command");
innerRan.set(false);
m_scheduler.run();
assertFalse(innerRan.get(), "Trigger should not have fired again");
}
@Test
void scopeGoingInactiveCancelsBoundCommand() {
var activeScope = new AtomicBoolean(true);
BindingScope scope = activeScope::get;
var triggerSignal = new AtomicBoolean(false);
var trigger = new Trigger(m_scheduler, triggerSignal::get);
var command = Command.noRequirements().executing(Coroutine::park).named("Command");
trigger.addBinding(scope, BindingType.RUN_WHILE_HIGH, command);
triggerSignal.set(true);
m_scheduler.run();
assertTrue(m_scheduler.isRunning(command), "Command should have started when triggered");
activeScope.set(false);
m_scheduler.run();
assertFalse(
m_scheduler.isRunning(command),
"Command should have been canceled when scope became inactive");
}
// The scheduler lifecycle polls triggers at the start of `run()`
// Even though the trigger condition is set, the command exits and the trigger's scope goes
// inactive before the next `run()` call can poll the trigger
@Test
void triggerFromExitingCommandDoesNotFire() {
var condition = new AtomicBoolean(false);
var triggeredCommandRan = new AtomicBoolean(false);
var inner =
Command.noRequirements()
.executing(
co -> {
triggeredCommandRan.set(true);
co.park();
})
.named("Inner");
var awaited =
Command.noRequirements()
.executing(
co -> {
co.yield();
condition.set(true);
})
.named("Awaited");
var outer =
Command.noRequirements()
.executing(
co -> {
new Trigger(m_scheduler, condition::get).onTrue(inner);
co.await(awaited);
})
.named("Outer");
m_scheduler.schedule(outer);
// First run: schedules `awaited`, yields
m_scheduler.run();
assertTrue(m_scheduler.isRunning(outer));
assertTrue(m_scheduler.isRunning(awaited));
assertEquals(
List.of("Outer", "Awaited"),
m_scheduler.getRunningCommands().stream().map(Command::name).toList());
// Second run: `awaited` resumes, sets the condition, exits. `outer` exits its final `yield`
// and will exit on the next run. The trigger condition has been set, but the trigger is checked
// on the next call to `run()`
m_scheduler.run();
assertEquals(List.of(), m_scheduler.getRunningCommands().stream().map(Command::name).toList());
assertTrue(condition.get(), "Condition wasn't set");
assertFalse(triggeredCommandRan.get(), "Command was unexpectedly triggered");
// Third run: trigger binding fires (outside a running command) and queues up `inner`.
// However, the inner command's lifetime is bound to `outer`, and is immediately canceled before
// it can run when the outer command exits.
m_scheduler.run();
assertEquals(List.of(), m_scheduler.getRunningCommands().stream().map(Command::name).toList());
assertFalse(triggeredCommandRan.get(), "Command was unexpectedly triggered");
}
}