diff --git a/e2e-tests/src/main/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/procedures/DeleteBiteBananasGoal.java b/e2e-tests/src/main/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/procedures/DeleteBiteBananasGoal.java new file mode 100644 index 0000000000..1a52f21141 --- /dev/null +++ b/e2e-tests/src/main/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/procedures/DeleteBiteBananasGoal.java @@ -0,0 +1,26 @@ +package gov.nasa.jpl.aerie.e2e.procedural.scheduling.procedures; + +import gov.nasa.ammos.aerie.procedural.scheduling.Goal; +import gov.nasa.ammos.aerie.procedural.scheduling.annotations.SchedulingProcedure; +import gov.nasa.ammos.aerie.procedural.scheduling.plan.DeletedAnchorStrategy; +import gov.nasa.ammos.aerie.procedural.scheduling.plan.EditablePlan; +import gov.nasa.ammos.aerie.procedural.timeline.payloads.activities.DirectiveStart; +import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; +import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; +import gov.nasa.jpl.aerie.types.ActivityDirectiveId; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * Deletes all Bite Bananas with extreme prejudice. Used to test that updated + * anchors are saved in the database properly. + */ +@SchedulingProcedure +public record DeleteBiteBananasGoal() implements Goal { + @Override + public void run(@NotNull final EditablePlan plan) { + plan.directives("BiteBanana").forEach($ -> plan.delete($, DeletedAnchorStrategy.PreserveTree)); + plan.commit(); + } +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/DatabaseDeletionTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/DatabaseDeletionTests.java new file mode 100644 index 0000000000..48c8fdc0a0 --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/DatabaseDeletionTests.java @@ -0,0 +1,177 @@ +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 javax.json.JsonValue; +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 DatabaseDeletionTests extends ProceduralSchedulingSetup { + private GoalInvocationId procedureId; + + @BeforeEach + void localBeforeEach() throws IOException { + try (final var gateway = new GatewayRequests(playwright)) { + int procedureJarId = gateway.uploadJarFile("build/libs/DeleteBiteBananaGoal.jar"); + // Add Scheduling Procedure + procedureId = hasura.createSchedulingSpecProcedure( + "Test Scheduling Procedure", + procedureJarId, + specId, + 0 + ); + } + } + + @AfterEach + void localAfterEach() throws IOException { + hasura.deleteSchedulingGoal(procedureId.goalId()); + } + + @Test + void deletesDirectiveAlreadyInDatabase() throws IOException { + hasura.insertActivityDirective( + planId, + "BiteBanana", + "1h", + JsonValue.EMPTY_JSON_OBJECT + ); + + var plan = hasura.getPlan(planId); + assertEquals(1, plan.activityDirectives().size()); + + hasura.awaitScheduling(specId); + + plan = hasura.getPlan(planId); + assertEquals(0, plan.activityDirectives().size()); + } + + @Test + void deletesDirectiveInDatabaseWithAnchor() throws IOException { + final var bite = hasura.insertActivityDirective( + planId, + "BiteBanana", + "1h", + JsonValue.EMPTY_JSON_OBJECT + ); + + final var grow = hasura.insertActivityDirective( + planId, + "GrowBanana", + "1h", + JsonValue.EMPTY_JSON_OBJECT, + Json.createObjectBuilder().add("anchor_id", bite) + ); + + var plan = hasura.getPlan(planId); + var activities = plan.activityDirectives(); + assertEquals(2, activities.size()); + assertTrue(activities.stream().anyMatch( + it -> Objects.equals(it.type(), "GrowBanana") + && Objects.equals(it.anchorId(), bite) + && Objects.equals(it.startOffset(), "01:00:00") + )); + + hasura.awaitScheduling(specId); + + plan = hasura.getPlan(planId); + + activities = plan.activityDirectives(); + assertEquals(1, activities.size()); + + assertTrue(activities.stream().anyMatch( + it -> Objects.equals(it.type(), "GrowBanana") + && Objects.equals(it.anchorId(), null) + && Objects.equals(it.startOffset(), "02:00:00") + )); + } + + @Test + void deletesDirectiveInDatabaseInMiddleOfChain() throws IOException { + + // Creates 5 activities, deletes "Bite". + // grow1 <- bite + // bite <- grow (id lost) + // bite <- grow2 + // grow2 <- grow3 + + // Bite has two children, a grandchild, and a parent. + + final var grow1 = hasura.insertActivityDirective( + planId, + "GrowBanana", + "1h", + JsonValue.EMPTY_JSON_OBJECT + ); + + final var bite = hasura.insertActivityDirective( + planId, + "BiteBanana", + "1h", + JsonValue.EMPTY_JSON_OBJECT, + Json.createObjectBuilder().add("anchor_id", grow1) + ); + + int grow2 = -1; + for (int i = 0; i < 2; i++) { + grow2 = hasura.insertActivityDirective( + planId, + "GrowBanana", + i + "h", + JsonValue.EMPTY_JSON_OBJECT, + Json.createObjectBuilder().add("anchor_id", bite) + ); + } + + final var grow3 = hasura.insertActivityDirective( + planId, + "GrowBanana", + "0h", + JsonValue.EMPTY_JSON_OBJECT, + Json.createObjectBuilder().add("anchor_id", grow2) + ); + + var plan = hasura.getPlan(planId); + var activities = plan.activityDirectives(); + assertEquals(5, activities.size()); + + hasura.awaitScheduling(specId); + + plan = hasura.getPlan(planId); + + activities = plan.activityDirectives(); + assertEquals(4, activities.size()); + + assertTrue(activities.stream().anyMatch( + it -> Objects.equals(it.type(), "GrowBanana") + && Objects.equals(it.id(), grow1) + && Objects.equals(it.anchorId(), null) + )); + final int finalGrow2 = grow2; + assertTrue(activities.stream().anyMatch( + it -> Objects.equals(it.type(), "GrowBanana") + && Objects.equals(it.id(), finalGrow2) + && Objects.equals(it.anchorId(), grow1) + && Objects.equals(it.startOffset(), "02:00:00") + )); + assertTrue(activities.stream().anyMatch( + it -> Objects.equals(it.type(), "GrowBanana") + && Objects.equals(it.anchorId(), grow1) + && Objects.equals(it.startOffset(), "01:00:00") + )); + assertTrue(activities.stream().anyMatch( + it -> Objects.equals(it.type(), "GrowBanana") + && Objects.equals(it.id(), grow3) + && Objects.equals(it.anchorId(), finalGrow2) + && Objects.equals(it.startOffset(), "00:00:00") + )); + } +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/DeletionTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/DeletionTests.java index 9cd51f98f5..2b49563d83 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/DeletionTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/DeletionTests.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import javax.json.Json; +import javax.json.JsonValue; import java.io.IOException; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java index 71b5dff661..c0394a518e 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/HasuraRequests.java @@ -11,6 +11,7 @@ import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonArrayBuilder; +import javax.json.JsonObjectBuilder; import javax.json.JsonValue; import javax.json.JsonObject; import java.io.IOException; @@ -225,12 +226,15 @@ public void deletePlan(int planId) throws IOException { makeRequest(GQL.DELETE_PLAN, variables); } - public int insertActivityDirective(int planId, String type, String startOffset, JsonObject arguments) throws IOException { + public int insertActivityDirective(int planId, String type, String startOffset, JsonObject arguments, JsonObjectBuilder ...extraArgs) throws IOException { final var insertActivityBuilder = Json.createObjectBuilder() .add("plan_id", planId) .add("type", type) .add("start_offset", startOffset) .add("arguments", arguments); + for (final var extraArg : extraArgs) { + insertActivityBuilder.addAll(extraArg); + } final var variables = Json.createObjectBuilder().add("activityDirectiveInsertInput", insertActivityBuilder).build(); return makeRequest(GQL.CREATE_ACTIVITY_DIRECTIVE, variables).getJsonObject("createActivityDirective").getInt("id"); } diff --git a/procedural/scheduling/src/main/kotlin/gov/nasa/ammos/aerie/procedural/scheduling/utils/DefaultEditablePlanDriver.kt b/procedural/scheduling/src/main/kotlin/gov/nasa/ammos/aerie/procedural/scheduling/utils/DefaultEditablePlanDriver.kt index 5ce5a294e8..5655280905 100644 --- a/procedural/scheduling/src/main/kotlin/gov/nasa/ammos/aerie/procedural/scheduling/utils/DefaultEditablePlanDriver.kt +++ b/procedural/scheduling/src/main/kotlin/gov/nasa/ammos/aerie/procedural/scheduling/utils/DefaultEditablePlanDriver.kt @@ -198,6 +198,9 @@ class DefaultEditablePlanDriver( for (d in directives) { // the when block is used to smart-cast d.start to an Anchor. This is basically just an if statement. // Basically we're just iterating through looking for activities anchored to the deleted one. + + // Kotlin doesn't smart cast objects whose origins it can't statically check for race conditions, + // which is why I have to bind `d.start` to a local variable. Then `childStart` can be smart cast. when (val childStart = d.start) { is DirectiveStart.Anchor -> { if (childStart.parentId == directive.id) { diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java index 5cabdd407a..7fa88aed4c 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/services/GraphQLMerlinDatabaseService.java @@ -572,16 +572,16 @@ mutation createAllPlanActivityDirectives($activities: [activity_directive_insert .add("anchored_to_start", act.anchoredToStart()); if (act.anchorId() != null) { - insertionObject.add("anchor_id", act.anchorId().toString()); + insertionObject.add("anchor_id", act.anchorId().id()); } if (act.name() != null) insertionObject = insertionObject.add("name", act.name()); //add duration to parameters if controllable final var insertionObjectArguments = Json.createObjectBuilder(); - if(act.getType().getDurationType() instanceof DurationType.Controllable durationType){ - if(!act.arguments().containsKey(durationType.parameterName())){ - insertionObjectArguments.add(durationType.parameterName(), serializedValueP.unparse(schedulerModel.serializeDuration(act.duration()))); + if(act.getType().getDurationType() instanceof DurationType.Controllable(String parameterName)){ + if(!act.arguments().containsKey(parameterName)){ + insertionObjectArguments.add(parameterName, serializedValueP.unparse(schedulerModel.serializeDuration(act.duration()))); } } @@ -679,7 +679,7 @@ private void deleteActivityDirectives( { if (ids.isEmpty()) return; ensurePlanExists(planId); - final var idString = ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + final var idString = ids.stream().map($ -> String.valueOf($.id())).collect(Collectors.joining(",")); final var request = """ mutation deletePlanActivityDirectives($planId: Int! = %d, $directiveIds: [Int!]! = [%s]) { delete_activity_directive(where: {_and: {plan_id: {_eq: $planId}, id: {_in: $directiveIds}}}) {