From 2914b7fe6dabcc98e277c02758a5ee2b4a5ce645 Mon Sep 17 00:00:00 2001 From: JoelCourtney Date: Fri, 13 Sep 2024 16:51:38 -0700 Subject: [PATCH] Refactor for procedural scheduling e2e tests --- .github/workflows/test.yml | 2 +- e2e-tests/build.gradle | 74 +++++++- ...ingTests.java => EdslSchedulingTests.java} | 159 +----------------- .../e2e/procedural/scheduling/BasicTests.java | 150 +++++++++++++++++ .../scheduling/ExternalProfilesTests.java | 60 +++++++ .../scheduling/ProceduralSchedulingSetup.java | 77 +++++++++ .../procedural/scheduling/package-info.java | 5 + .../procedures/DumbRecurrenceGoal.java | 34 ++++ 8 files changed, 400 insertions(+), 161 deletions(-) rename e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/{SchedulingTests.java => EdslSchedulingTests.java} (88%) create mode 100644 e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicTests.java create mode 100644 e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ExternalProfilesTests.java create mode 100644 e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ProceduralSchedulingSetup.java create mode 100644 e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/package-info.java create mode 100644 e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/procedures/DumbRecurrenceGoal.java diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45697a5882..a2df13e1c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,7 +78,7 @@ jobs: - name: Assemble run: ./gradlew assemble --parallel - name: Build scheduling procedure jars for testing - run: ./gradlew procedural:examples:foo-procedures:buildAllSchedulingProcedureJars + run: ./gradlew e2e-tests:buildAllSchedulingProcedureJars - name: Start Services run: | docker compose -f ./e2e-tests/docker-compose-test.yml up -d --build diff --git a/e2e-tests/build.gradle b/e2e-tests/build.gradle index 4ff48fdab6..4965a753f3 100644 --- a/e2e-tests/build.gradle +++ b/e2e-tests/build.gradle @@ -1,6 +1,9 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + plugins { id 'java-library' id 'jacoco' + id 'com.gradleup.shadow' version '8.3.0' } java { @@ -54,12 +57,17 @@ task e2eTest(type: Test) { } dependencies { + testAnnotationProcessor project(':procedural:processor') + + testImplementation project(":procedural:scheduling") testImplementation project(":procedural:timeline") + testImplementation project(':merlin-driver') + testImplementation project(':type-utils') + testImplementation project(':contrib') + testImplementation project(":procedural:remote") testImplementation "com.zaxxer:HikariCP:5.1.0" testImplementation("org.postgresql:postgresql:42.6.0") - testImplementation project(':merlin-driver') - testImplementation project(':type-utils') testImplementation 'com.microsoft.playwright:playwright:1.37.0' @@ -69,3 +77,65 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.0' } + +tasks.register('buildAllSchedulingProcedureJars') { + group = 'SchedulingProcedureJars' + + dependsOn "generateSchedulingProcedureJarTasks" + dependsOn { + tasks.findAll { task -> task.name.startsWith('buildSchedulingProcedureJar_') } + } +} + +tasks.create("generateSchedulingProcedureJarTasks") { + group = 'SchedulingProcedureJars' + + final proceduresDir = findFirstMatchingBuildDir("generated/procedures") + + if (proceduresDir == null) { + println "No procedures folder found" + return + } + println "Generating jar tasks for the following procedures directory: ${proceduresDir}" + + final files = file(proceduresDir).listFiles() + if (files.length == 0) { + println "No procedures available within folder ${proceduresDir}" + return + } + + files.toList().each { file -> + final nameWithoutExtension = file.name.replace(".java", "") + final taskName = "buildSchedulingProcedureJar_${nameWithoutExtension}" + + println "Generating ${taskName} task, which will build ${nameWithoutExtension}.jar" + + tasks.create(taskName, ShadowJar) { + group = 'SchedulingProcedureJars' + configurations = [project.configurations.testCompileClasspath] + from sourceSets.main.output + archiveBaseName = "" // clear + archiveClassifier.set(nameWithoutExtension) // set output jar name + manifest { + attributes 'Main-Class': getMainClassFromGeneratedFile(file) + } + minimize() + } + } +} + +private String findFirstMatchingBuildDir(String pattern) { + String found = null + final generatedDir = file("build/generated/sources") + generatedDir.mkdirs() + generatedDir.eachDirRecurse { dir -> if (dir.path.contains(pattern)) found = dir.path } + return found +} + +private static String getMainClassFromGeneratedFile(File file) { + final fileString = file.toString() + final prefix = "build/generated/sources/annotationProcessor/java/main/" + final index = fileString.indexOf(prefix) + prefix.length() + final trimmed = fileString.substring(index).replace(".java", "") + return trimmed.replace("/", ".") +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/SchedulingTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/EdslSchedulingTests.java similarity index 88% rename from e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/SchedulingTests.java rename to e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/EdslSchedulingTests.java index 7251b504b4..0a030a3b9a 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/SchedulingTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/EdslSchedulingTests.java @@ -3,7 +3,6 @@ import com.microsoft.playwright.Playwright; import gov.nasa.jpl.aerie.e2e.types.ExternalDataset.ProfileInput; import gov.nasa.jpl.aerie.e2e.types.ExternalDataset.ProfileInput.ProfileSegmentInput; -import gov.nasa.jpl.aerie.e2e.types.GoalInvocationId; import gov.nasa.jpl.aerie.e2e.types.Plan; import gov.nasa.jpl.aerie.e2e.types.ProfileSegment; import gov.nasa.jpl.aerie.e2e.types.SchedulingRequest.SchedulingStatus; @@ -29,7 +28,6 @@ import java.util.Arrays; import java.util.Comparator; import java.util.List; -import java.util.Objects; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -38,7 +36,7 @@ import static org.junit.jupiter.api.Assertions.fail; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class SchedulingTests { +public class EdslSchedulingTests { // Requests private Playwright playwright; private HasuraRequests hasura; @@ -1002,159 +1000,4 @@ void schedulingIgnoreDisabledGoals() throws IOException { } } } - - @Nested - class ProceduralSchedulingTests { - private int modelId; - private int planId; - private int specId; - private int procedureJarId; - private GoalInvocationId procedureId; - - @BeforeEach - void beforeEach() throws IOException, InterruptedException { - try (final var gateway = new GatewayRequests(playwright)) { - modelId = hasura.createMissionModel( - gateway.uploadJarFile(), - "Banananation (e2e tests)", - "aerie_e2e_tests", - "Proc Scheduling Tests"); - - procedureJarId = gateway.uploadJarFile("../procedural/examples/foo-procedures/build/libs/SampleProcedure.jar"); - } - // Insert the Plan - planId = hasura.createPlan( - modelId, - "Proc Sched Plan - Proc Scheduling Tests", - "48:00:00", - planStartTimestamp); - specId = hasura.getSchedulingSpecId(planId); - - // Add Scheduling Procedure - procedureId = hasura.createSchedulingSpecProcedure( - "Test Scheduling Procedure", - procedureJarId, - specId, - 0); - } - - @AfterEach - void afterEach() throws IOException { - hasura.deleteSchedulingGoal(procedureId.goalId()); - hasura.deletePlan(planId); - hasura.deleteMissionModel(modelId); - } - - /** - * Upload a procedure jar and add to spec - */ - @Test - void proceduralUploadWorks() throws IOException { - final var ids = hasura.getSchedulingSpecGoalIds(specId); - - assertEquals(1, ids.size()); - assertEquals(procedureId.goalId(), ids.getFirst()); - } - - /** - * Run a spec with one procedure in it with required params but no args set - * Should fail scheduling run - */ - @Test - void executeSchedulingRunWithoutArguments() throws IOException { - final var resp = hasura.awaitFailingScheduling(specId); - final var message = resp.reason().getString("message"); - assertTrue(message.contains("java.lang.RuntimeException: Record missing key Component[name=quantity")); - } - - /** - * Run a spec with one procedure in it - */ - @Test - void executeSchedulingRunWithArguments() throws IOException { - final var args = Json.createObjectBuilder().add("quantity", 2).build(); - - hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); - - final var resp = hasura.awaitScheduling(specId); - - final var plan = hasura.getPlan(planId); - final var activities = plan.activityDirectives(); - - assertEquals(2, activities.size()); - - assertTrue(activities.stream().anyMatch( - $ -> Objects.equals($.type(), "BiteBanana") && Objects.equals($.startOffset(), "24:00:00") - )); - - assertTrue(activities.stream().anyMatch( - $ -> Objects.equals($.type(), "BiteBanana") && Objects.equals($.startOffset(), "30:00:00") - )); - } - - /** - * Run a spec with two invocations of the same procedure in it - */ - @Test - void executeMultipleInvocationsOfSameProcedure() throws IOException { - final var args = Json.createObjectBuilder().add("quantity", 2).build(); - hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); - - final var secondInvocationId = hasura.insertGoalInvocation(procedureId.goalId(), specId); - hasura.updateSchedulingSpecGoalArguments(secondInvocationId.invocationId(), args); - - final var resp = hasura.awaitScheduling(specId); - - final var plan = hasura.getPlan(planId); - final var activities = plan.activityDirectives(); - - assertEquals(4, activities.size()); - } - - /** - * Run a spec with two procedures in it - */ - @Test - void executeMultipleProcedures() throws IOException { - final var args = Json.createObjectBuilder().add("quantity", 2).build(); - hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); - - final var secondProcedure = hasura.createSchedulingSpecProcedure( - "Test Scheduling Procedure 2", - procedureJarId, - specId, - 1); - - hasura.updateSchedulingSpecGoalArguments(secondProcedure.invocationId(), args); - - final var resp = hasura.awaitScheduling(specId); - - final var plan = hasura.getPlan(planId); - final var activities = plan.activityDirectives(); - - assertEquals(4, activities.size()); - } - - /** - * Run a spec with one EDSL goal and one procedure - */ - @Test - void executeEDSLAndProcedure() throws IOException { - final var args = Json.createObjectBuilder().add("quantity", 4).build(); - hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); - - final int recurrenceGoalId = hasura.createSchedulingSpecGoal( - "Recurrence Scheduling Test Goal", - recurrenceGoalDefinition, - specId, - 1).goalId(); - - final var resp = hasura.awaitScheduling(specId); - - final var plan = hasura.getPlan(planId); - final var activities = plan.activityDirectives(); - - assertEquals(52, activities.size()); - } - } } diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicTests.java new file mode 100644 index 0000000000..38e678ab59 --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicTests.java @@ -0,0 +1,150 @@ +package gov.nasa.jpl.aerie.e2e.procedural.scheduling; + +import gov.nasa.jpl.aerie.e2e.types.GoalInvocationId; +import gov.nasa.jpl.aerie.e2e.utils.GatewayRequests; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.json.Json; +import java.io.IOException; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class BasicTests extends ProceduralSchedulingSetup { + private int procedureJarId; + private GoalInvocationId procedureId; + + @BeforeEach + void localBeforeEach() throws IOException { + try (final var gateway = new GatewayRequests(playwright)) { + procedureJarId = gateway.uploadJarFile("build/libs/DumbRecurrenceGoal.jar"); + // Add Scheduling Procedure + procedureId = hasura.createSchedulingSpecProcedure( + "Test Scheduling Procedure", + procedureJarId, + specId, + 0 + ); + } + } + + @AfterEach + void localAfterEach() throws IOException { + hasura.deleteSchedulingGoal(procedureId.goalId()); + } + + /** + * Upload a procedure jar and add to spec + */ + @Test + void proceduralUploadWorks() throws IOException { + final var ids = hasura.getSchedulingSpecGoalIds(specId); + + assertEquals(1, ids.size()); + assertEquals(procedureId.goalId(), ids.getFirst()); + } + + /** + * Run a spec with one procedure in it with required params but no args set + * Should fail scheduling run + */ + @Test + void executeSchedulingRunWithoutArguments() throws IOException { + final var resp = hasura.awaitFailingScheduling(specId); + final var message = resp.reason().getString("message"); + assertTrue(message.contains("java.lang.RuntimeException: Record missing key Component[name=quantity")); + } + + /** + * Run a spec with one procedure in it + */ + @Test + void executeSchedulingRunWithArguments() throws IOException { + final var args = Json.createObjectBuilder().add("quantity", 2).build(); + + hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); + + hasura.awaitScheduling(specId); + + final var plan = hasura.getPlan(planId); + final var activities = plan.activityDirectives(); + + assertEquals(2, activities.size()); + + assertTrue(activities.stream().anyMatch( + $ -> Objects.equals($.type(), "BiteBanana") && Objects.equals($.startOffset(), "24:00:00") + )); + + assertTrue(activities.stream().anyMatch( + $ -> Objects.equals($.type(), "BiteBanana") && Objects.equals($.startOffset(), "30:00:00") + )); + } + + /** + * Run a spec with two invocations of the same procedure in it + */ + @Test + void executeMultipleInvocationsOfSameProcedure() throws IOException { + final var args = Json.createObjectBuilder().add("quantity", 2).build(); + hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); + + final var secondInvocationId = hasura.insertGoalInvocation(procedureId.goalId(), specId); + hasura.updateSchedulingSpecGoalArguments(secondInvocationId.invocationId(), args); + + hasura.awaitScheduling(specId); + + final var plan = hasura.getPlan(planId); + final var activities = plan.activityDirectives(); + + assertEquals(4, activities.size()); + } + + /** + * Run a spec with two procedures in it + */ + @Test + void executeMultipleProcedures() throws IOException { + final var args = Json.createObjectBuilder().add("quantity", 2).build(); + hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); + + final var secondProcedure = hasura.createSchedulingSpecProcedure( + "Test Scheduling Procedure 2", + procedureJarId, + specId, + 1); + + hasura.updateSchedulingSpecGoalArguments(secondProcedure.invocationId(), args); + + hasura.awaitScheduling(specId); + + final var plan = hasura.getPlan(planId); + final var activities = plan.activityDirectives(); + + assertEquals(4, activities.size()); + } + + /** + * Run a spec with one EDSL goal and one procedure + */ + @Test + void executeEDSLAndProcedure() throws IOException { + final var args = Json.createObjectBuilder().add("quantity", 4).build(); + hasura.updateSchedulingSpecGoalArguments(procedureId.invocationId(), args); + + hasura.createSchedulingSpecGoal( + "Recurrence Scheduling Test Goal", + recurrenceGoalDefinition, + specId, + 1); + + hasura.awaitScheduling(specId); + + final var plan = hasura.getPlan(planId); + final var activities = plan.activityDirectives(); + + assertEquals(52, activities.size()); + } +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ExternalProfilesTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ExternalProfilesTests.java new file mode 100644 index 0000000000..974deba0cf --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ExternalProfilesTests.java @@ -0,0 +1,60 @@ +package gov.nasa.jpl.aerie.e2e.procedural.scheduling; + +import gov.nasa.jpl.aerie.e2e.ExternalDatasetsTest; +import gov.nasa.jpl.aerie.e2e.types.GoalInvocationId; +import gov.nasa.jpl.aerie.e2e.utils.GatewayRequests; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ExternalProfilesTests extends ProceduralSchedulingSetup { + private GoalInvocationId procedureId; + private int datasetId; + + @BeforeEach + void localBeforeEach() throws IOException { + try (final var gateway = new GatewayRequests(playwright)) { + int procedureJarId = gateway.uploadJarFile("build/libs/ExternalProfileGoal.jar"); + // Add Scheduling Procedure + procedureId = hasura.createSchedulingSpecProcedure( + "Test Scheduling Procedure", + procedureJarId, + specId, + 0 + ); + + datasetId = hasura.insertExternalDataset( + planId, + "2023-001T01:00:00.000", + List.of(ExternalDatasetsTest.myBooleanProfile) + ); + } + } + + @AfterEach + void localAfterEach() throws IOException { + hasura.deleteSchedulingGoal(procedureId.goalId()); + hasura.deleteExternalDataset(planId, datasetId); + } + + @Test + void testQueryExternalProfiles() throws IOException { + hasura.awaitScheduling(specId); + + final var plan = hasura.getPlan(planId); + final var activities = plan.activityDirectives(); + + assertEquals(1, activities.size()); + + assertTrue(activities.stream().anyMatch( + $ -> Objects.equals($.type(), "BiteBanana") && Objects.equals($.startOffset(), "03:00:00") + )); + } +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ProceduralSchedulingSetup.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ProceduralSchedulingSetup.java new file mode 100644 index 0000000000..c1ef5853b1 --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/ProceduralSchedulingSetup.java @@ -0,0 +1,77 @@ +package gov.nasa.jpl.aerie.e2e.procedural.scheduling; + +import com.microsoft.playwright.Playwright; +import gov.nasa.jpl.aerie.e2e.types.GoalInvocationId; +import gov.nasa.jpl.aerie.e2e.utils.GatewayRequests; +import gov.nasa.jpl.aerie.e2e.utils.HasuraRequests; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; + +import java.io.IOException; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public abstract class ProceduralSchedulingSetup { + + // Requests + protected Playwright playwright; + protected HasuraRequests hasura; + + // Per-Test Data + protected int modelId; + protected int planId; + protected int specId; + + // Cross-Test Constants + protected final String planStartTimestamp = "2023-01-01T00:00:00+00:00"; + protected final String recurrenceGoalDefinition = + """ + export default function myGoal() { + return Goal.ActivityRecurrenceGoal({ + activityTemplate: ActivityTemplates.PeelBanana({peelDirection: 'fromStem'}), + interval: Temporal.Duration.from({hours:1}) + })}"""; + + @BeforeAll + void beforeAll() { + // Setup Requests + playwright = Playwright.create(); + hasura = new HasuraRequests(playwright); + } + + @AfterAll + void afterAll() { + // Cleanup Requests + hasura.close(); + playwright.close(); + } + + @BeforeEach + void beforeEach() throws IOException, InterruptedException { + try (final var gateway = new GatewayRequests(playwright)) { + modelId = hasura.createMissionModel( + gateway.uploadJarFile(), + "Banananation (e2e tests)", + "aerie_e2e_tests", + "Proc Scheduling Tests for subclass: %s".formatted(this.getClass().getSimpleName())); + + + } + // Insert the Plan + planId = hasura.createPlan( + modelId, + "Proc Sched Plan - Proc Scheduling Tests for subclass: %s".formatted(this.getClass().getSimpleName()), + "48:00:00", + planStartTimestamp); + + specId = hasura.getSchedulingSpecId(planId); + } + + @AfterEach + void afterEach() throws IOException { + hasura.deletePlan(planId); + hasura.deleteMissionModel(modelId); + } +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/package-info.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/package-info.java new file mode 100644 index 0000000000..c77565fa77 --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/package-info.java @@ -0,0 +1,5 @@ +@WithMappers(BasicValueMappers.class) +package gov.nasa.jpl.aerie.e2e.procedural.scheduling; + +import gov.nasa.jpl.aerie.contrib.serialization.rulesets.BasicValueMappers; +import gov.nasa.ammos.aerie.procedural.scheduling.annotations.WithMappers; diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/procedures/DumbRecurrenceGoal.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/procedures/DumbRecurrenceGoal.java new file mode 100644 index 0000000000..98d0687a10 --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/procedures/DumbRecurrenceGoal.java @@ -0,0 +1,34 @@ +package gov.nasa.jpl.aerie.e2e.procedural.scheduling.procedures; + +import gov.nasa.ammos.aerie.procedural.scheduling.plan.EditablePlan; +import gov.nasa.ammos.aerie.procedural.scheduling.Goal; +import gov.nasa.ammos.aerie.procedural.scheduling.annotations.SchedulingProcedure; +import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; +import gov.nasa.ammos.aerie.procedural.timeline.payloads.activities.DirectiveStart; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * Waits 24hrs into the plan, then places `quantity` number of BiteBanana activities, + * one every 6hrs. + */ +@SchedulingProcedure +public record DumbRecurrenceGoal(int quantity) implements Goal { + @Override + public void run(@NotNull final EditablePlan plan) { + final var firstTime = Duration.hours(24); + final var step = Duration.hours(6); + + var currentTime = firstTime; + for (var i = 0; i < quantity; i++) { + plan.create( + "BiteBanana", + new DirectiveStart.Absolute(currentTime), + Map.of() + ); + currentTime = currentTime.plus(step); + } + plan.commit(); + } +}