From 8549c55d8886853241836daaf9197e34b665e71d Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Mon, 27 Apr 2026 16:54:44 -0400 Subject: [PATCH 1/3] Fix edge case in anchor validation Before, when checking whether an activity had a negative offset relative to plan_start, it would skip activities that had a positive start offset, even if their total offset was negative (ie, due to the activity it was anchored to having a large negative offset). --- .../nasa/jpl/aerie/database/AnchorTests.java | 31 ++++++++ .../Aerie/33_fix_anchor_validation/down.sql | 43 +++++++++++ .../Aerie/33_fix_anchor_validation/up.sql | 45 +++++++++++ .../sql/applied_migrations.sql | 1 + .../anchor_validation_status.sql | 74 +++++++++---------- 5 files changed, 157 insertions(+), 37 deletions(-) create mode 100644 deployment/hasura/migrations/Aerie/33_fix_anchor_validation/down.sql create mode 100644 deployment/hasura/migrations/Aerie/33_fix_anchor_validation/up.sql diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java index e52dff6b0a..da500e81ab 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java @@ -606,6 +606,37 @@ void negativeToPlanStart() throws SQLException { assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); } + /** + * Activities further down the chain are flagged if their total start offset relative to plan start is negative. + */ + @Test + void negativeToPlanStartWithPositiveChild() throws SQLException { + final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); + final PGInterval nineMinutes = new PGInterval("9 minutes"); + final PGInterval elevenMinutes = new PGInterval("11 minutes"); + + final int planId = merlinHelper.insertPlan(missionModelId); + + final int unrelatedActId = merlinHelper.insertActivity(planId); + final int parentId = merlinHelper.insertActivity(planId, minusTenMinutes.toString()); + final int childId = insertActivityWithAnchor(planId, nineMinutes, parentId, true); + + // Only the unrelated activity is valid, as parent has a net start offset of "-10 mins", + // and childhas a net start offset of "-1 minute" + final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); + final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentId); + final AnchorValidationStatus childStatus = getValidationStatus(planId, childId); + assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); + assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus.reasonInvalid); + assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", childStatus.reasonInvalid); + + // After updating the child's start offset to "11 minutes", its net offset relative to plan start becomes "1 minute", + // making it now valid. parent is unaffected. + updateOffsetFromAnchor(elevenMinutes, childId, planId); + assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus.reasonInvalid); + assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); + } + @Test void negativeToPlanStartDownChain() throws SQLException { final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); diff --git a/deployment/hasura/migrations/Aerie/33_fix_anchor_validation/down.sql b/deployment/hasura/migrations/Aerie/33_fix_anchor_validation/down.sql new file mode 100644 index 0000000000..5b9d66e331 --- /dev/null +++ b/deployment/hasura/migrations/Aerie/33_fix_anchor_validation/down.sql @@ -0,0 +1,43 @@ +create or replace procedure merlin.validate_nonegative_net_plan_start(_activity_id integer, _plan_id integer) + security definer + language plpgsql as $$ + declare + net_offset interval; + _anchor_id integer; + _start_offset interval; + _anchored_to_start boolean; + begin + select anchor_id, start_offset, anchored_to_start + from merlin.activity_directive + where (id, plan_id) = (_activity_id, _plan_id) + into _anchor_id, _start_offset, _anchored_to_start; + + if (_start_offset < '0' and _anchored_to_start) then -- only need to check if anchored to start or something with a negative offset + with recursive anchors(activity_id, anchor_id, anchored_to_start, start_offset, total_offset) as ( + select _activity_id, _anchor_id, _anchored_to_start, _start_offset, _start_offset + union + select ad.id, ad.anchor_id, ad.anchored_to_start, ad.start_offset, anchors.total_offset + ad.start_offset + from merlin.activity_directive ad, anchors + where anchors.anchor_id is not null -- stop at plan + and (ad.id, ad.plan_id) = (anchors.anchor_id, _plan_id) + and anchors.anchored_to_start -- or, stop at end-time offset + ) + select total_offset -- get the id of the activity that the selected activity is anchored to + from anchors a + where a.anchor_id is null + and a.anchored_to_start + limit 1 + into net_offset; + + if(net_offset < '0') then + raise notice 'Activity Directive % has a net negative offset relative to Plan Start.', _activity_id; + + insert into merlin.anchor_validation_status (activity_id, plan_id, reason_invalid) + values (_activity_id, _plan_id, 'Activity Directive ' || _activity_id || ' has a net negative offset relative to Plan Start.') + on conflict (activity_id, plan_id) do update + set reason_invalid = 'Activity Directive ' || excluded.activity_id || ' has a net negative offset relative to Plan Start.'; + end if; + end if; + end + $$; +call migrations.mark_migration_rolled_back(33); diff --git a/deployment/hasura/migrations/Aerie/33_fix_anchor_validation/up.sql b/deployment/hasura/migrations/Aerie/33_fix_anchor_validation/up.sql new file mode 100644 index 0000000000..7cfbf23ee2 --- /dev/null +++ b/deployment/hasura/migrations/Aerie/33_fix_anchor_validation/up.sql @@ -0,0 +1,45 @@ +create or replace procedure merlin.validate_nonegative_net_plan_start(_activity_id integer, _plan_id integer) + security definer + language plpgsql as $$ +declare + net_offset interval; + _anchor_id integer; + _start_offset interval; + _anchored_to_start boolean; +begin + select anchor_id, start_offset, anchored_to_start + from merlin.activity_directive + where (id, plan_id) = (_activity_id, _plan_id) + into _anchor_id, _start_offset, _anchored_to_start; + + if (_anchored_to_start) then -- only need to check if anchored to start + with recursive anchors(activity_id, anchor_id, anchored_to_start, start_offset, total_offset) as ( + select _activity_id, _anchor_id, _anchored_to_start, _start_offset, _start_offset + union + select ad.id, ad.anchor_id, ad.anchored_to_start, ad.start_offset, anchors.total_offset + ad.start_offset + from merlin.activity_directive ad, anchors + where anchors.anchor_id is not null -- stop at plan + and (ad.id, ad.plan_id) = (anchors.anchor_id, _plan_id) + and anchors.anchored_to_start -- or, stop at end-time offset + ) + select total_offset -- get the id of the activity that the selected activity is anchored to + from anchors a + where a.anchor_id is null + and a.anchored_to_start + limit 1 + into net_offset; + + if(net_offset < '0') then + raise notice 'Activity Directive % has a net negative offset relative to Plan Start.', _activity_id; + + insert into merlin.anchor_validation_status (activity_id, plan_id, reason_invalid) + values (_activity_id, _plan_id, 'Activity Directive ' || _activity_id || ' has a net negative offset relative to Plan Start.') + on conflict (activity_id, plan_id) do update + set reason_invalid = 'Activity Directive ' || excluded.activity_id || ' has a net negative offset relative to Plan Start.'; + end if; + end if; + end +$$; + +call migrations.mark_migration_applied(33); + diff --git a/deployment/postgres-init-db/sql/applied_migrations.sql b/deployment/postgres-init-db/sql/applied_migrations.sql index 69f6176042..778d9dfd6a 100644 --- a/deployment/postgres-init-db/sql/applied_migrations.sql +++ b/deployment/postgres-init-db/sql/applied_migrations.sql @@ -34,3 +34,4 @@ call migrations.mark_migration_applied(29); call migrations.mark_migration_applied(30); call migrations.mark_migration_applied(31); call migrations.mark_migration_applied(32); +call migrations.mark_migration_applied(33); diff --git a/deployment/postgres-init-db/sql/tables/merlin/activity_directive/anchor_validation_status.sql b/deployment/postgres-init-db/sql/tables/merlin/activity_directive/anchor_validation_status.sql index 260d5b625e..5484b89736 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/activity_directive/anchor_validation_status.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/activity_directive/anchor_validation_status.sql @@ -107,45 +107,45 @@ comment on procedure merlin.validate_nonnegative_net_end_offset(_activity_id int create procedure merlin.validate_nonegative_net_plan_start(_activity_id integer, _plan_id integer) security definer language plpgsql as $$ - declare - net_offset interval; - _anchor_id integer; - _start_offset interval; - _anchored_to_start boolean; - begin - select anchor_id, start_offset, anchored_to_start - from merlin.activity_directive - where (id, plan_id) = (_activity_id, _plan_id) - into _anchor_id, _start_offset, _anchored_to_start; - - if (_start_offset < '0' and _anchored_to_start) then -- only need to check if anchored to start or something with a negative offset - with recursive anchors(activity_id, anchor_id, anchored_to_start, start_offset, total_offset) as ( - select _activity_id, _anchor_id, _anchored_to_start, _start_offset, _start_offset - union - select ad.id, ad.anchor_id, ad.anchored_to_start, ad.start_offset, anchors.total_offset + ad.start_offset - from merlin.activity_directive ad, anchors - where anchors.anchor_id is not null -- stop at plan - and (ad.id, ad.plan_id) = (anchors.anchor_id, _plan_id) - and anchors.anchored_to_start -- or, stop at end-time offset - ) - select total_offset -- get the id of the activity that the selected activity is anchored to - from anchors a - where a.anchor_id is null - and a.anchored_to_start - limit 1 - into net_offset; +declare + net_offset interval; + _anchor_id integer; + _start_offset interval; + _anchored_to_start boolean; +begin + select anchor_id, start_offset, anchored_to_start + from merlin.activity_directive + where (id, plan_id) = (_activity_id, _plan_id) + into _anchor_id, _start_offset, _anchored_to_start; - if(net_offset < '0') then - raise notice 'Activity Directive % has a net negative offset relative to Plan Start.', _activity_id; + if (_anchored_to_start) then -- only need to check if anchored to start + with recursive anchors(activity_id, anchor_id, anchored_to_start, start_offset, total_offset) as ( + select _activity_id, _anchor_id, _anchored_to_start, _start_offset, _start_offset + union + select ad.id, ad.anchor_id, ad.anchored_to_start, ad.start_offset, anchors.total_offset + ad.start_offset + from merlin.activity_directive ad, anchors + where anchors.anchor_id is not null -- stop at plan + and (ad.id, ad.plan_id) = (anchors.anchor_id, _plan_id) + and anchors.anchored_to_start -- or, stop at end-time offset + ) + select total_offset -- get the id of the activity that the selected activity is anchored to + from anchors a + where a.anchor_id is null + and a.anchored_to_start + limit 1 + into net_offset; + + if(net_offset < '0') then + raise notice 'Activity Directive % has a net negative offset relative to Plan Start.', _activity_id; - insert into merlin.anchor_validation_status (activity_id, plan_id, reason_invalid) - values (_activity_id, _plan_id, 'Activity Directive ' || _activity_id || ' has a net negative offset relative to Plan Start.') - on conflict (activity_id, plan_id) do update - set reason_invalid = 'Activity Directive ' || excluded.activity_id || ' has a net negative offset relative to Plan Start.'; - end if; - end if; - end - $$; + insert into merlin.anchor_validation_status (activity_id, plan_id, reason_invalid) + values (_activity_id, _plan_id, 'Activity Directive ' || _activity_id || ' has a net negative offset relative to Plan Start.') + on conflict (activity_id, plan_id) do update + set reason_invalid = 'Activity Directive ' || excluded.activity_id || ' has a net negative offset relative to Plan Start.'; + end if; + end if; + end +$$; comment on procedure merlin.validate_nonegative_net_plan_start(_activity_id integer, _plan_id integer) is e'' 'Returns true if the specified activity has a net negative offset from plan start. Otherwise, returns false.\n' 'If true, writes to anchor_validation_status.'; From a244fb233c12d996850c8643a39167ab93b8f45b Mon Sep 17 00:00:00 2001 From: Theresa Kamerman Date: Thu, 30 Apr 2026 11:08:13 -0400 Subject: [PATCH 2/3] Refactor AnchorTests Tests now: - are better commented - use JUnit's "assertThrows" instead of try-catch - are more direct - have shared setup code pulled out into the BeforeEach --- .../nasa/jpl/aerie/database/AnchorTests.java | 1315 ++++++++--------- .../database/MerlinDatabaseTestHelper.java | 6 + 2 files changed, 651 insertions(+), 670 deletions(-) diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java index da500e81ab..2255fc7ace 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java @@ -1,5 +1,9 @@ package gov.nasa.jpl.aerie.database; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.postgresql.util.PGInterval; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; @@ -13,11 +17,13 @@ import java.sql.Connection; import java.sql.SQLException; import java.util.ArrayList; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -30,11 +36,15 @@ public class AnchorTests { private Connection connection; int fileId; int missionModelId; + int planId; + int unrelatedActId; @BeforeEach void beforeEach() throws SQLException { fileId = merlinHelper.insertFileUpload(); missionModelId = merlinHelper.insertMissionModel(fileId); + planId = merlinHelper.insertPlan(missionModelId); + unrelatedActId = merlinHelper.insertActivity(planId); // This activity should always be valid. } @AfterEach @@ -55,7 +65,6 @@ void afterAll() throws SQLException, IOException, InterruptedException { } //region Helper Methods - private void updateOffsetFromAnchor(PGInterval newOffset, int activityId, int planId) throws SQLException { try(final var statement = connection.createStatement()) { statement.execute( @@ -167,10 +176,27 @@ private record Activity( int activityId, int planId, PGInterval startOffset, - String anchorId, // Since anchor_id allows for null values, this is a String to avoid confusion over what the number means. + Integer anchorId, // Since anchor_id allows for null values, this is an Integer to avoid confusion over what a number means. boolean anchoredToStart, String approximateStartTime - ) {} + ) { + private Activity( + int activityId, + int planId, + PGInterval startOffset, + String anchorId, + boolean anchoredToStart, + String approximateStartTime + ) { + this( + activityId, + planId, + startOffset, + anchorId == null ? null : Integer.valueOf(anchorId), + anchoredToStart, + approximateStartTime); + } + } private record AnchorValidationStatus(int activityId, int planId, String reasonInvalid) {} //endregion @@ -182,9 +208,8 @@ void createAnchor() throws SQLException { final PGInterval oneDay = new PGInterval("1 day"); final PGInterval tenMinutes = new PGInterval("10 minutes"); - final int planId = merlinHelper.insertPlan(missionModelId); final int anchorActId = merlinHelper.insertActivity(planId); - final int otherActId = merlinHelper.insertActivity(planId, oneDay.toString()); + final int otherActId = merlinHelper.insertActivity(planId, oneDay); // Assert that otherActId has an anchor of null but an offset equal to the input Activity otherActivity = getActivity(planId, otherActId); @@ -199,7 +224,7 @@ void createAnchor() throws SQLException { otherActivity = getActivity(planId, otherActId); assertNotNull(otherActivity.anchorId); - assertEquals(anchorActId, Integer.valueOf(otherActivity.anchorId)); + assertEquals(anchorActId, otherActivity.anchorId); assertFalse(otherActivity.anchoredToStart); assertEquals(tenMinutes, otherActivity.startOffset); assertEquals("2020-01-01 00:10:00+00", otherActivity.approximateStartTime); @@ -209,809 +234,748 @@ void createAnchor() throws SQLException { assertEquals("2020-01-01 00:00:00+00", anchorActivity.approximateStartTime); } + /** + * An activity can't be directly anchored to itself. + */ @Test void cantAnchorToSelf() throws SQLException { - final int planId = merlinHelper.insertPlan(missionModelId); final int activityId = merlinHelper.insertActivity(planId); - - try { - merlinHelper.setAnchor(activityId, true, activityId, planId); - fail(); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot anchor activity " + activityId + " to itself.")) { - throw ex; - } - } + final var sqlEx = assertThrows(SQLException.class, + () -> merlinHelper.setAnchor(activityId, true, activityId, planId)); + assertTrue(sqlEx.getMessage().contains("Cannot anchor activity " + activityId + " to itself.")); } + /** + * An activity can't be indirectly anchored to itself via an anchor cycle + */ @Test void noCyclesInAnchors() throws SQLException { - final int planId = merlinHelper.insertPlan(missionModelId); final int actAId = merlinHelper.insertActivity(planId); final int actBId = merlinHelper.insertActivity(planId); merlinHelper.setAnchor(actAId, true, actBId, planId); - try { - merlinHelper.setAnchor(actBId, true, actAId, planId); - fail(); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cycle detected. Cannot apply changes.")) { - throw ex; - } - } + final var sqlEx = assertThrows(SQLException.class, + () -> merlinHelper.setAnchor(actBId, true, actAId, planId)); + assertTrue(sqlEx.getMessage().contains("Cycle detected. Cannot apply changes.")); } - // This additionally tests that invalid anchor ids fail + /** + * An activity's anchor must exist in the same plan as the activity. + */ @Test void cannotAnchorToActivityNotInPlan() throws SQLException { - final int planId = merlinHelper.insertPlan(missionModelId); final int activityId = merlinHelper.insertActivity(planId); - try { - merlinHelper.setAnchor(-10, true, activityId, planId); - fail(); - } catch (SQLException ex) { - if (!ex.getMessage().contains( - "insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"")) { - throw ex; - } - } + final int otherPlanId = merlinHelper.insertPlan(missionModelId); + final int otherPlanActivity = merlinHelper.insertActivity(otherPlanId); + + final var sqlEx = assertThrows(SQLException.class, + () -> merlinHelper.setAnchor(otherPlanActivity, true, activityId, planId)); + assertTrue(sqlEx.getMessage().contains("insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"")); } } + /** + * Validation tests focusing on cases where an activity has a negative start offset relative to the end time of its anchor. + */ @Nested class NetNegativeEndTimeStatus { + /** + * Once an activity no longer has a Negative Start Offset relative to the end time of its anchor, + * the warning status is cleared. + */ @Test - void negativeEndTimeOffsetWritesToStatus() throws SQLException { - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); + void invalidMessageClearedUponResolution() throws SQLException { + final var minusTenMinutes = new PGInterval("-10 minutes"); + final var zeroSeconds = new PGInterval("0 seconds"); + final var tenMinutes = new PGInterval("10 minutes"); - final int planId = merlinHelper.insertPlan(missionModelId); - final int grandparentActId = merlinHelper.insertActivity(planId); - final int parentActId = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), grandparentActId, false); - final int negOffsetActId = merlinHelper.insertActivity(planId, minusTenMinutes.toString()); - final int childActId = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), negOffsetActId, true); - final int unrelatedActId = merlinHelper.insertActivity(planId); - - // Invalid regarding Plan Start - final AnchorValidationStatus negOffsetStatus = getValidationStatus(planId, negOffsetActId); - final AnchorValidationStatus childStatus = getValidationStatus(planId, childActId); - final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentActId); - final AnchorValidationStatus grandparentStatus = getValidationStatus(planId, grandparentActId); - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); - assertEquals("Activity Directive "+negOffsetActId +" has a net negative offset relative to Plan Start.", negOffsetStatus.reasonInvalid); - assertTrue(childStatus.reasonInvalid.isEmpty()); - assertTrue(parentStatus.reasonInvalid.isEmpty()); - assertTrue(grandparentStatus.reasonInvalid.isEmpty()); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); + // Create a chain of anchored activities + final int parentActId = merlinHelper.insertActivity(planId); + final int baseActId = insertActivityWithAnchor(planId, minusTenMinutes, parentActId, false); + final int childActId = insertActivityWithAnchor(planId, zeroSeconds, baseActId, true); - // Invalid relative to parent - merlinHelper.setAnchor(parentActId, false, negOffsetActId, planId); - assertEquals("Activity Directive " +negOffsetActId +" has a net negative offset relative to an end-time" - + " anchor on Activity Directive " +parentActId +".", refresh(negOffsetStatus).reasonInvalid); - assertEquals("Activity Directive " +childActId +" has a net negative offset relative to an end-time" - + " anchor on Activity Directive " +parentActId +".", refresh(childStatus).reasonInvalid); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(grandparentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId); + final var parentStatus = getValidationStatus(planId, parentActId); + final var baseStatus = getValidationStatus(planId, baseActId); + final var childStatus = getValidationStatus(planId, childActId); - // Valid relative to parent, invalid relative to grandparent - merlinHelper.setAnchor(parentActId, true, negOffsetActId, planId); - assertEquals("Activity Directive " +negOffsetActId +" has a net negative offset relative to an end-time" - + " anchor on Activity Directive " +grandparentActId +".", refresh(negOffsetStatus).reasonInvalid); + // Base and Child are currently invalid + assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); + assertTrue(parentStatus.reasonInvalid.isEmpty()); + assertEquals("Activity Directive " +baseActId +" has a net negative offset relative to an end-time" + + " anchor on Activity Directive " +parentActId +".", baseStatus.reasonInvalid); assertEquals("Activity Directive " +childActId +" has a net negative offset relative to an end-time" - + " anchor on Activity Directive " +grandparentActId +".", refresh(childStatus).reasonInvalid); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(grandparentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + + " anchor on Activity Directive " +parentActId +".", childStatus.reasonInvalid); - // Valid regrading Plan End - // This also validates that the validation status is cleared when updated to a valid value - merlinHelper.setAnchor(-1, false, negOffsetActId, planId ); - assertTrue(refresh(negOffsetStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(grandparentStatus).reasonInvalid.isEmpty()); + // Update base to have a positive offset relative to the end time of parent, making it and child valid + updateOffsetFromAnchor(tenMinutes, baseActId, planId); + + // The warning messages have been cleared assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); + assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); + assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); } + /** + * In the event an activity is both before the plan start and has a Negative Start Offset relative to the + * end time of its anchor, the warning for the Negative Start Offset takes priority + */ @Test - void immediateDescendentBecomesInvalid() throws SQLException { - final PGInterval fiveMinutes = new PGInterval("5 minutes"); - final PGInterval fifteenMinutes = new PGInterval("15 minutes"); + void netNegativeEndTimeTakesPriority() throws SQLException { final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); // Should always be valid. - - final int parentActId = merlinHelper.insertActivity(planId); - final int baseActId = merlinHelper.insertActivity(planId, fifteenMinutes.toString()); - - // Create a chain - final int childActId = insertActivityWithAnchor(planId, minusTenMinutes, baseActId, true); + final int parentId = merlinHelper.insertActivity(planId, minusTenMinutes); + final int childId = insertActivityWithAnchor(planId, minusTenMinutes, parentId, false); - final AnchorValidationStatus unrelatedValidation = getValidationStatus(planId, unrelatedActId); - final AnchorValidationStatus baseValidation = getValidationStatus(planId, baseActId); - final AnchorValidationStatus childValidation = getValidationStatus(planId, childActId); - assertTrue(unrelatedValidation.reasonInvalid.isEmpty()); - assertTrue(baseValidation.reasonInvalid.isEmpty()); - assertTrue(childValidation.reasonInvalid.isEmpty()); - - // Anchoring base to the end of parent does not invalidate anything. - merlinHelper.setAnchor(parentActId, false, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - - // Shortening base's offset makes child invalid - updateOffsetFromAnchor(fiveMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertEquals("Activity Directive " +childActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", refresh(childValidation).reasonInvalid); + // Get a handle on the validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentId).reasonInvalid; + final var childStatus = getValidationStatus(planId, childId).reasonInvalid; - // Updating base back makes child valid. - updateOffsetFromAnchor(fifteenMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); + // Both parent and child are invalid, but for different stated reasons + assertTrue(unrelatedStatus.isEmpty()); + assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus); + assertEquals("Activity Directive " + childId +" has a net negative offset " + + "relative to an end-time anchor on Activity Directive " +parentId +".", childStatus); } + /** + * Case: + * Activity B has a negative anchor relative to the end of Activity A. + * Behavior: + * Activity B has an invalid anchor. + */ @Test - void childAndGrandchildBecomeInvalid() throws SQLException { - final PGInterval fiveMinutes = new PGInterval("5 minutes"); - final PGInterval fifteenMinutes = new PGInterval("15 minutes"); - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); // Should always be valid. + void baseActivityIsInvalid() throws SQLException { + final var minusTenMinutes = new PGInterval("-10 minutes"); + // Create a chain of anchored activities final int parentActId = merlinHelper.insertActivity(planId); - final int baseActId = merlinHelper.insertActivity(planId, fifteenMinutes.toString()); + final int baseActId = insertActivityWithAnchor(planId, minusTenMinutes, parentActId, false); + + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentActId).reasonInvalid; + final var baseStatus = getValidationStatus(planId, baseActId).reasonInvalid; + + // The base activity has a negative start offset relative to the parent activity, so it is flagged as invalid + assertTrue(unrelatedStatus.isEmpty()); + assertTrue(parentStatus.isEmpty()); + assertEquals("Activity Directive " +baseActId +" has a net negative offset relative to an end-time" + + " anchor on Activity Directive " +parentActId +".", baseStatus); + } + + /** + * Case: + * Activity B has a positive anchor relative to the end of Activity A. + * Activity C has a negative anchor relative to the start of Activity B, + * where the magnitude of its anchor is greater than Activity B's start offset. + * Behavior: + * Activity C has an invalid anchor. + */ + @Test + void invalidChild() throws SQLException { + final var fiveMinutes = new PGInterval("5 minutes"); + final var minusTenMinutes = new PGInterval("-10 minutes"); // Create a chain + final int parentActId = merlinHelper.insertActivity(planId); + final int baseActId = insertActivityWithAnchor(planId, fiveMinutes, parentActId, false); final int childActId = insertActivityWithAnchor(planId, minusTenMinutes, baseActId, true); - final int grandchildActId = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), childActId, true); - - final AnchorValidationStatus unrelatedValidation = getValidationStatus(planId, unrelatedActId); - final AnchorValidationStatus baseValidation = getValidationStatus(planId, baseActId); - final AnchorValidationStatus childValidation = getValidationStatus(planId, childActId); - final AnchorValidationStatus grandchildValidation = getValidationStatus(planId, grandchildActId); - assertTrue(unrelatedValidation.reasonInvalid.isEmpty()); - assertTrue(baseValidation.reasonInvalid.isEmpty()); - assertTrue(childValidation.reasonInvalid.isEmpty()); - assertTrue(grandchildValidation.reasonInvalid.isEmpty()); - - // Anchoring base to the end of parent does not invalidate anything due to size of base's offset. - merlinHelper.setAnchor(parentActId, false, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertEquals("", refresh(baseValidation).reasonInvalid); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(grandchildValidation).reasonInvalid.isEmpty()); - - // Shortening base's offset makes child and grandchild invalid - updateOffsetFromAnchor(fiveMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); + + // Get a handle on their validation statuses + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentValidation = getValidationStatus(planId, parentActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var childValidation = getValidationStatus(planId, childActId).reasonInvalid; + + // Base activity is invalid, as its net offset from the end of grandparent is -5 minutes + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(parentValidation.isEmpty()); + assertTrue(baseValidation.isEmpty()); assertEquals("Activity Directive " +childActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", refresh(childValidation).reasonInvalid); - assertEquals("Activity Directive " +grandchildActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", refresh(grandchildValidation).reasonInvalid); - - // Restoring base makes child and grandchild valid. - updateOffsetFromAnchor(fifteenMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(grandchildValidation).reasonInvalid.isEmpty()); + + "relative to an end-time anchor on Activity Directive " +parentActId +".", childValidation); } - /* - * Similar test to childAndGrandchildBecomeInvalid, except grandchild having an end-time anchor will prevent it from - * being checked (as whether it's invalid relative to parent's end time depends on the duration of child and base, which is unknowable at anchoring time) + /** + * Case: + * Activity B has a positive anchor relative to the end of Activity A. + * Activity C has a negative anchor relative to the start of Activity B, + * where the magnitude of its anchor is less than or equal to Activity B's start offset. + * Behavior: + * No activities have invalid anchors. + */ + @ParameterizedTest + @ValueSource(strings = {"-5 minutes", "-10 minutes"}) + void validChild(String childInterval) throws SQLException { + final var tenMinutes = new PGInterval("10 minutes"); + + // Create a chain + final int parentActId = merlinHelper.insertActivity(planId, tenMinutes); + final int baseActId = insertActivityWithAnchor(planId, tenMinutes, parentActId, false); + final int childActId = insertActivityWithAnchor(planId, new PGInterval(childInterval), baseActId, true); + + // Get a handle on their validation statuses + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentValidation = getValidationStatus(planId, parentActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var childValidation = getValidationStatus(planId, childActId).reasonInvalid; + + // No activity is invalid + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(parentValidation.isEmpty()); + assertTrue(baseValidation.isEmpty()); + assertTrue(childValidation.isEmpty()); + } + + /** + * Case: + * Activity B has a negative anchor relative to the end of Activity A. + * Activity C has a positive anchor relative to the start of Activity B, + * where the magnitude of its anchor is less than Activity B's start offset + * Behavior: + * Both activity B and C have invalid anchors */ @Test - void grandchildEndTimeAnchorIsIgnored() throws SQLException { - final PGInterval fiveMinutes = new PGInterval("5 minutes"); + void indirectlyInvalidChild() throws SQLException { final PGInterval fifteenMinutes = new PGInterval("15 minutes"); final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); + final PGInterval fiveMinutes = new PGInterval("5 minutes"); - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); // Should always be valid. + // Create a chain + final int parentActId = merlinHelper.insertActivity(planId, fifteenMinutes); + final int baseActId = insertActivityWithAnchor(planId, minusTenMinutes, parentActId, false); + final int childActId = insertActivityWithAnchor(planId, fiveMinutes, baseActId, true); - final int parentActId = merlinHelper.insertActivity(planId); - final int baseActId = merlinHelper.insertActivity(planId, fifteenMinutes.toString()); + // Get a handle on the validation statuses + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentValidation = getValidationStatus(planId, parentActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var childValidation = getValidationStatus(planId, childActId).reasonInvalid; - // Create a chain - final int childActId = insertActivityWithAnchor(planId, minusTenMinutes, baseActId, true); - final int grandchildActId = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), childActId, false); - - final AnchorValidationStatus unrelatedValidation = getValidationStatus(planId, unrelatedActId); - final AnchorValidationStatus baseValidation = getValidationStatus(planId, baseActId); - final AnchorValidationStatus childValidation = getValidationStatus(planId, childActId); - final AnchorValidationStatus grandchildValidation = getValidationStatus(planId, grandchildActId); - assertTrue(unrelatedValidation.reasonInvalid.isEmpty()); - assertTrue(baseValidation.reasonInvalid.isEmpty()); - assertTrue(childValidation.reasonInvalid.isEmpty()); - assertTrue(grandchildValidation.reasonInvalid.isEmpty()); - - // Anchoring base to the end of parent does not invalidate anything due to size of base's offset. - merlinHelper.setAnchor(parentActId, false, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(grandchildValidation).reasonInvalid.isEmpty()); - - // Shortening base's offset makes child invalid and does not affect grandchild. - updateOffsetFromAnchor(fiveMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); + // Base and Child should both have warning messages, as they both have a negative offset relative to Parent + // (-10 mins and -5 mins, respectively) + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(parentValidation.isEmpty()); + assertEquals("Activity Directive " +baseActId +" has a net negative offset " + + "relative to an end-time anchor on Activity Directive " +parentActId +".", baseValidation); assertEquals("Activity Directive " +childActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", refresh(childValidation).reasonInvalid); - assertTrue(refresh(grandchildValidation).reasonInvalid.isEmpty()); - - // Restoring base makes child valid. - updateOffsetFromAnchor(fifteenMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(grandchildValidation).reasonInvalid.isEmpty()); + + "relative to an end-time anchor on Activity Directive " +parentActId +".", childValidation); } - @Test - void onlyGrandchildBecomesInvalid() throws SQLException { - final PGInterval fiveMinutes = new PGInterval("5 minutes"); + /** + * Case: + * Activity B has a negative anchor relative to the end of Activity A. + * Activity C has a positive anchor relative to the start of Activity B, + * where the magnitude of its anchor is greater than or equal to Activity B's start offset + * Behavior: + * Only activity B has an invalid anchor + */ + @ParameterizedTest + @ValueSource(strings = {"10 minutes", "15 minutes"}) + void onlyInvalidParent(String childInterval) throws SQLException { final PGInterval fifteenMinutes = new PGInterval("15 minutes"); final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); // Should always be empty. + // Create a chain + final int parentActId = merlinHelper.insertActivity(planId, fifteenMinutes); + final int baseActId = insertActivityWithAnchor(planId, minusTenMinutes, parentActId, false); + final int childActId = insertActivityWithAnchor(planId, new PGInterval(childInterval), baseActId, true); - final int parentActId = merlinHelper.insertActivity(planId); - final int baseActId = merlinHelper.insertActivity(planId, fifteenMinutes.toString()); + // Get a handle on the validation statuses + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentValidation = getValidationStatus(planId, parentActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var childValidation = getValidationStatus(planId, childActId).reasonInvalid; - // Create a chain - final int childActId = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), baseActId, true); - final int grandchildActId = insertActivityWithAnchor(planId, minusTenMinutes, childActId, true); - - final AnchorValidationStatus unrelatedValidation = getValidationStatus(planId, unrelatedActId); - final AnchorValidationStatus baseValidation = getValidationStatus(planId, baseActId); - final AnchorValidationStatus childValidation = getValidationStatus(planId, childActId); - final AnchorValidationStatus grandchildValidation = getValidationStatus(planId, grandchildActId); - assertTrue(unrelatedValidation.reasonInvalid.isEmpty()); - assertTrue(baseValidation.reasonInvalid.isEmpty()); - assertTrue(childValidation.reasonInvalid.isEmpty()); - assertTrue(grandchildValidation.reasonInvalid.isEmpty()); - - // Anchoring base to the end of parent does not invalidate anything due to size of base's offset. - merlinHelper.setAnchor(parentActId, false, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(grandchildValidation).reasonInvalid.isEmpty()); - - // Shortening base's offset makes grandchild invalid - updateOffsetFromAnchor(fiveMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - assertEquals("Activity Directive " +grandchildActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", refresh(grandchildValidation).reasonInvalid); - - // Restoring base makes grandchild valid. - updateOffsetFromAnchor(fifteenMinutes, baseActId, planId); - assertTrue(refresh(unrelatedValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(baseValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(childValidation).reasonInvalid.isEmpty()); - assertTrue(refresh(grandchildValidation).reasonInvalid.isEmpty()); + // Only Base should both have a warning message + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(parentValidation.isEmpty()); + assertEquals("Activity Directive " +baseActId +" has a net negative offset " + + "relative to an end-time anchor on Activity Directive " +parentActId +".", baseValidation); + assertTrue(childValidation.isEmpty()); } + /** + * Case: + * Activity B has a negative anchor relative to the end of Activity A. + * Activity C has a positive anchor relative to the end of Activity B. + * Behavior: + * Only activity B has an invalid anchor. + * Because C is anchored to the end of B, whether its invalid relative to A depends on the duration of B. + * As such, anchor validation only checks to the nearest end anchor. + */ @Test - void childIsInvalid() throws SQLException { + void onlyNearestEndTimeAnchorChecked() throws SQLException { final PGInterval fiveMinutes = new PGInterval("5 minutes"); final PGInterval fifteenMinutes = new PGInterval("15 minutes"); final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); // Should always be empty. - - final int parentActId = merlinHelper.insertActivity(planId, fifteenMinutes.toString()); - // Create a chain + final int parentActId = merlinHelper.insertActivity(planId, fifteenMinutes); final int baseActId = insertActivityWithAnchor(planId, minusTenMinutes, parentActId, false); - final int childActId = insertActivityWithAnchor(planId, fiveMinutes, baseActId, true); + final int childActId = insertActivityWithAnchor(planId, fiveMinutes, baseActId, false); - final AnchorValidationStatus unrelatedValidation = getValidationStatus(planId, unrelatedActId); - final AnchorValidationStatus parentValidation = getValidationStatus(planId, parentActId); - final AnchorValidationStatus baseValidation = getValidationStatus(planId, baseActId); - final AnchorValidationStatus childValidation = getValidationStatus(planId, childActId); + // Get a handle on the validation statuses + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentValidation = getValidationStatus(planId, parentActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var childValidation = getValidationStatus(planId, childActId).reasonInvalid; - assertTrue(unrelatedValidation.reasonInvalid.isEmpty()); - assertTrue(parentValidation.reasonInvalid.isEmpty()); + // Only Base is marked as invalid + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(parentValidation.isEmpty()); assertEquals("Activity Directive " +baseActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", baseValidation.reasonInvalid); - assertEquals("Activity Directive " +childActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", childValidation.reasonInvalid); + + "relative to an end-time anchor on Activity Directive " +parentActId +".", baseValidation); + assertTrue(childValidation.isEmpty()); } + /** + * Case: + * Activity B is anchored to the end of Activity A with a negative start offset. + * There is a long chain of activities anchored to the start of B with an start offset of 0. + * Behavior: + * Both B and the entire chain of activities attached to B are invalid. + */ @Test - void farDescendantIsInvalid() throws SQLException { - // Parent, base is anchored to end with negative offset, 100 anchors to start of base with 0 offset. Both parent and child should be invalid - final PGInterval fiveMinutes = new PGInterval("5 minutes"); - final PGInterval fifteenMinutes = new PGInterval("15 minutes"); - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); // Should always be valid. - - final int parentActId = merlinHelper.insertActivity(planId, fifteenMinutes.toString()); + void invalidFarDescendant() throws SQLException { + final var fifteenMinutes = new PGInterval("15 minutes"); + final var minusTenMinutes = new PGInterval("-10 minutes"); + final var zeroSeconds = new PGInterval("0 seconds"); // Create a chain + final int parentActId = merlinHelper.insertActivity(planId, fifteenMinutes); final int baseActId = insertActivityWithAnchor(planId, minusTenMinutes, parentActId, false); - final int[] interimActIds = new int[100]; - interimActIds[0] = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), baseActId, true); + final int[] chainActIds = new int[100]; + chainActIds[0] = insertActivityWithAnchor(planId, zeroSeconds, baseActId, true); for(int i = 1; i < 100; i++){ - interimActIds[i] = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), interimActIds[i-1], true); + chainActIds[i] = insertActivityWithAnchor(planId, zeroSeconds, chainActIds[i-1], true); } - final int childActId = insertActivityWithAnchor(planId, fiveMinutes, interimActIds[99], true); - final AnchorValidationStatus unrelatedValidation = getValidationStatus(planId, unrelatedActId); - final AnchorValidationStatus parentValidation = getValidationStatus(planId, parentActId); - final AnchorValidationStatus baseValidation = getValidationStatus(planId, baseActId); - final AnchorValidationStatus[] interimValidations = new AnchorValidationStatus[100]; + // Get a handle on all the validations + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentValidation = getValidationStatus(planId, parentActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var chainValidations = new String[100]; for(int i = 0; i < 100; i++){ - interimValidations[i] = getValidationStatus(planId, interimActIds[i]); + chainValidations[i] = getValidationStatus(planId, chainActIds[i]).reasonInvalid; } - final AnchorValidationStatus childValidation = getValidationStatus(planId, childActId); - assertTrue(unrelatedValidation.reasonInvalid.isEmpty()); - assertTrue(parentValidation.reasonInvalid.isEmpty()); + // Everything besides parent and the unrelated activity are invalid + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(parentValidation.isEmpty()); assertEquals("Activity Directive " +baseActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", baseValidation.reasonInvalid); + + "relative to an end-time anchor on Activity Directive " +parentActId +".", baseValidation); for(int i = 0; i < 100; i++){ - assertEquals("Activity Directive " +interimActIds[i] +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", interimValidations[i].reasonInvalid); + assertEquals("Activity Directive " +chainActIds[i] +" has a net negative offset " + + "relative to an end-time anchor on Activity Directive " +parentActId +".", chainValidations[i]); } - assertEquals("Activity Directive " +childActId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +parentActId +".", childValidation.reasonInvalid); } } + /** + * Validation tests focusing on cases where an activity has a negative start offset relative to the start of the plan. + */ @Nested class NetNegativePlanStartStatus { + /** + * Once an activity no longer has a Negative Start Offset relative to the start of the plan, + * the warning status is cleared. + */ @Test - void negativeToPlanStart() throws SQLException { - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final PGInterval tenMinutes = new PGInterval("10 minutes"); + void invalidMessageClearedUponResolution() throws SQLException { + final var minusTenMinutes = new PGInterval("-10 minutes"); + final var tenMinutes = new PGInterval("10 minutes"); - final int planId = merlinHelper.insertPlan(missionModelId); - final int activityId = merlinHelper.insertActivity(planId, tenMinutes.toString()); - final int unrelatedId = merlinHelper.insertActivity(planId); + final int activityId = merlinHelper.insertActivity(planId, minusTenMinutes); - // Valid regarding Plan Start - final AnchorValidationStatus activityStatus = getValidationStatus(planId, activityId); - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedId); - assertTrue(activityStatus.reasonInvalid.isEmpty()); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId); + final var activityStatus = getValidationStatus(planId, activityId); - // Make Invalid Relative to Plan Start - updateOffsetFromAnchor(minusTenMinutes, activityId, planId); - assertEquals("Activity Directive " +activityId +" has a net negative offset relative to Plan Start.", refresh(activityStatus).reasonInvalid); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Only activity has an invalid status + assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); + assertEquals("Activity Directive " +activityId +" has a net negative offset relative to Plan Start.", activityStatus.reasonInvalid); - // Restoring base clears the warning + // Update activity to have a positive offset relative to the end time of parent, making it valid updateOffsetFromAnchor(tenMinutes, activityId, planId); - assertTrue(refresh(activityStatus).reasonInvalid.isEmpty()); + + // The warning message has been cleared assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + assertTrue(refresh(activityStatus).reasonInvalid.isEmpty()); } + /** - * Activities further down the chain are flagged if their total start offset relative to plan start is negative. + * Activities directly anchored to plan start cannot have a negative start offset. */ @Test - void negativeToPlanStartWithPositiveChild() throws SQLException { + void negativeToPlanStart() throws SQLException { final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final PGInterval nineMinutes = new PGInterval("9 minutes"); - final PGInterval elevenMinutes = new PGInterval("11 minutes"); + final int activityId = merlinHelper.insertActivity(planId, minusTenMinutes); - final int planId = merlinHelper.insertPlan(missionModelId); + // Activity is invalid + final var activityStatus = getValidationStatus(planId, activityId).reasonInvalid; + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + assertEquals("Activity Directive " +activityId +" has a net negative offset relative to Plan Start.", activityStatus); + assertTrue(unrelatedStatus.isEmpty()); + } - final int unrelatedActId = merlinHelper.insertActivity(planId); - final int parentId = merlinHelper.insertActivity(planId, minusTenMinutes.toString()); + /** + * Case: + * Activity A has a negative start offset relative to plan start. + * Activity B has a positive start offset relative to the start of A, + * where the magnitude of its anchor is less than Activity A's start offset + * Result: + * Activity A and B are both invalid. + */ + @Test + void indirectlyInvalidChild() throws SQLException { + final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); + final PGInterval nineMinutes = new PGInterval("9 minutes"); + + final int parentId = merlinHelper.insertActivity(planId, minusTenMinutes); final int childId = insertActivityWithAnchor(planId, nineMinutes, parentId, true); - // Only the unrelated activity is valid, as parent has a net start offset of "-10 mins", - // and childhas a net start offset of "-1 minute" - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); - final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentId); - final AnchorValidationStatus childStatus = getValidationStatus(planId, childId); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); - assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus.reasonInvalid); - assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", childStatus.reasonInvalid); + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentId).reasonInvalid; + final var childStatus = getValidationStatus(planId, childId).reasonInvalid; - // After updating the child's start offset to "11 minutes", its net offset relative to plan start becomes "1 minute", - // making it now valid. parent is unaffected. - updateOffsetFromAnchor(elevenMinutes, childId, planId); - assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus.reasonInvalid); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); + // Only the unrelated activity is valid, as parent has a net start offset of "-10 mins", + // and child has a net start offset of "-1 minute" + assertTrue(unrelatedStatus.isEmpty()); + assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus); + assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", childStatus); } - @Test - void negativeToPlanStartDownChain() throws SQLException { - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final PGInterval elevenMinutes = new PGInterval("11 minutes"); - - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); - final int grandparentActId = merlinHelper.insertActivity(planId, elevenMinutes.toString()); - final int parentActId = insertActivityWithAnchor(planId, minusTenMinutes, grandparentActId, true); - final int baseId = insertActivityWithAnchor(planId, elevenMinutes, parentActId, true); - final int childId = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), baseId, true); - - // Base is currently valid regarding Plan Start - final AnchorValidationStatus baseStatus = getValidationStatus(planId, baseId); - final AnchorValidationStatus childStatus = getValidationStatus(planId, childId); - final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentActId); - final AnchorValidationStatus grandparentStatus = getValidationStatus(planId, grandparentActId); - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); - assertTrue(baseStatus.reasonInvalid.isEmpty()); - assertTrue(childStatus.reasonInvalid.isEmpty()); - assertTrue(parentStatus.reasonInvalid.isEmpty()); - assertTrue(grandparentStatus.reasonInvalid.isEmpty()); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); - - // Update makes base and child invalid relative to Plan Start - updateOffsetFromAnchor(minusTenMinutes, baseId, planId); - assertEquals("Activity Directive " +baseId +" has a net negative offset relative to Plan Start.", refresh(baseStatus).reasonInvalid); - assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", refresh(childStatus).reasonInvalid); - //assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(grandparentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + /** + * Case: + * Activity A has a negative start offset relative to plan start. + * Activity B has a positive start offset relative to the start of A, + * where the magnitude of its anchor is greater than or equal to Activity A's start offset + * Result: + * Only Activity A is invalid. + */ + @ParameterizedTest + @ValueSource(strings = {"10 minutes", "15 minutes"}) + void onlyInvalidParent(String childOffset) throws SQLException { + final int parentId = merlinHelper.insertActivity(planId, new PGInterval("-10 minutes")); + final int childId = insertActivityWithAnchor(planId, new PGInterval(childOffset), parentId, true); + + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentId).reasonInvalid; + final var childStatus = getValidationStatus(planId, childId).reasonInvalid; + + // Only the parent is invalid, as child is within the plan bounds + assertTrue(unrelatedStatus.isEmpty()); + assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus); + assertTrue(childStatus.isEmpty()); + } - // Restoring the anchor clears the warnings - updateOffsetFromAnchor(elevenMinutes, baseId, planId); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(grandparentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + /** + * Case: + * Activity A has a negative start offset relative to plan start. + * Activity B has a positive start offset relative to the end of A. + * Result: + * Only Activity A is invalid. + * Because B is anchored to the end of A, whether its invalid relative to plan start depends on the duration of A. + * As such, anchor validation only checks to the nearest end anchor. + */ + @ParameterizedTest + @ValueSource(strings = {"0 minutes", "9 minutes", "10 minutes", "11 minutes"}) + void onlyNearestEndTimeAnchorChecked(String childOffset) throws SQLException { + final int parentId = merlinHelper.insertActivity(planId, new PGInterval("-10 minutes")); + final int childId = insertActivityWithAnchor(planId, new PGInterval(childOffset), parentId, false); + + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentId).reasonInvalid; + final var childStatus = getValidationStatus(planId, childId).reasonInvalid; + + // Only parent is flagged as invalid + assertTrue(unrelatedStatus.isEmpty()); + assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus); + assertTrue(childStatus.isEmpty()); } + /** + * Case: + * Activity A has a negative start offset relative to plan start. + * Activity B has a negative start offset relative to start of A. + * Result: + * Both Activities A and B are invalid. + */ @Test - void immediateDescendentBecomesInvalid() throws SQLException { + void invalidParentAndChild() throws SQLException { final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final PGInterval minusFifteenMinutes = new PGInterval("-15 minutes"); - final PGInterval twentyMinutes = new PGInterval("20 minutes"); - - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); - - final int parentId = merlinHelper.insertActivity(planId, twentyMinutes.toString()); - final int baseId = insertActivityWithAnchor(planId, twentyMinutes, parentId, true); - final int childId = insertActivityWithAnchor(planId, minusTenMinutes, baseId, true); - - // Everything is currently valid regarding Plan Start - final AnchorValidationStatus childStatus = getValidationStatus(planId, childId); - final AnchorValidationStatus baseStatus = getValidationStatus(planId, baseId); - final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentId); - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); - assertTrue(childStatus.reasonInvalid.isEmpty()); - assertTrue(baseStatus.reasonInvalid.isEmpty()); - assertTrue(parentStatus.reasonInvalid.isEmpty()); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); - // Update to base makes child and grandchild invalid relative to Plan Start - updateOffsetFromAnchor(minusFifteenMinutes, baseId, planId); - assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", refresh(childStatus).reasonInvalid); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + final int parentActId = merlinHelper.insertActivity(planId, minusTenMinutes); + final int childId = insertActivityWithAnchor(planId, minusTenMinutes, parentActId, true); - // Restoring base clears the warning on child - updateOffsetFromAnchor(twentyMinutes, baseId, planId); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentActId).reasonInvalid; + final var childStatus = getValidationStatus(planId, childId).reasonInvalid; + + // Both activities are invalid + assertTrue(unrelatedStatus.isEmpty()); + assertEquals("Activity Directive " +parentActId +" has a net negative offset relative to Plan Start.", parentStatus); + assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", childStatus); } + /** + * Case: + * Activity A has a positive start offset relative to plan start. + * Activity B has a negative start offset relative to start of A, + * where the magnitude of its anchor is greater than Activity A's start offset + * Result: + * Only Activity B is invalid. + */ @Test - void childAndGrandchildBecomeInvalid() throws SQLException { - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); + void invalidChild() throws SQLException { + final PGInterval tenMinutes = new PGInterval("10 minutes"); final PGInterval minusFifteenMinutes = new PGInterval("-15 minutes"); - final PGInterval twentyMinutes = new PGInterval("20 minutes"); - - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); - - final int parentId = merlinHelper.insertActivity(planId, twentyMinutes.toString()); - final int baseId = insertActivityWithAnchor(planId, twentyMinutes, parentId, true); - final int childId = insertActivityWithAnchor(planId, minusTenMinutes, baseId, true); - final int grandchildId = insertActivityWithAnchor(planId, minusTenMinutes, childId, true); - - // Everything is currently valid regarding Plan Start - final AnchorValidationStatus grandChildStatus = getValidationStatus(planId, grandchildId); - final AnchorValidationStatus childStatus = getValidationStatus(planId, childId); - final AnchorValidationStatus baseStatus = getValidationStatus(planId, baseId); - final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentId); - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); - assertTrue(grandChildStatus.reasonInvalid.isEmpty()); - assertTrue(childStatus.reasonInvalid.isEmpty()); - assertTrue(baseStatus.reasonInvalid.isEmpty()); - assertTrue(parentStatus.reasonInvalid.isEmpty()); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); - // Update to base makes child and grandchild invalid relative to Plan Start - updateOffsetFromAnchor(minusFifteenMinutes, baseId, planId); - assertEquals("Activity Directive " +grandchildId +" has a net negative offset relative to Plan Start.", refresh(grandChildStatus).reasonInvalid); - assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", refresh(childStatus).reasonInvalid); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + final int parentId = merlinHelper.insertActivity(planId, tenMinutes); + final int childId = insertActivityWithAnchor(planId, minusFifteenMinutes, parentId, true); - // Restoring base clears the warnings on child and grandchild - updateOffsetFromAnchor(twentyMinutes, baseId, planId); - assertTrue(refresh(grandChildStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentId).reasonInvalid; + final var childStatus = getValidationStatus(planId, childId).reasonInvalid; + + // Only child activity is invalid + assertTrue(unrelatedStatus.isEmpty()); + assertTrue(parentStatus.isEmpty()); + assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", childStatus); } + /** + * Case: + * Activity A has a start offset relative to plan start of 0. + * There is a long chain of activities anchored to the start of A with a start offset of -1s. + * Behavior: + * The entire chain of activities attached to A are invalid. + */ @Test - void grandchildEndTimeAnchorIsIgnored() throws SQLException { - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final PGInterval minusFifteenMinutes = new PGInterval("-15 minutes"); - final PGInterval twentyMinutes = new PGInterval("20 minutes"); - - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); - - final int parentId = merlinHelper.insertActivity(planId, twentyMinutes.toString()); - final int baseId = insertActivityWithAnchor(planId, twentyMinutes, parentId, true); - final int childId = insertActivityWithAnchor(planId, minusTenMinutes, baseId, true); - final int grandchildId = insertActivityWithAnchor(planId, minusTenMinutes, childId, false); - - // Everything is currently valid regarding Plan Start - // Except grandchild, which is invalid regarding child's end time - final AnchorValidationStatus grandChildStatus = getValidationStatus(planId, grandchildId); - final AnchorValidationStatus childStatus = getValidationStatus(planId, childId); - final AnchorValidationStatus baseStatus = getValidationStatus(planId, baseId); - final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentId); - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); - assertEquals("Activity Directive " +grandchildId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +childId +".", grandChildStatus.reasonInvalid); - assertTrue(childStatus.reasonInvalid.isEmpty()); - assertTrue(baseStatus.reasonInvalid.isEmpty()); - assertTrue(parentStatus.reasonInvalid.isEmpty()); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); + void invalidChain() throws SQLException { + final var zeroSeconds = new PGInterval("0 seconds"); + final var minusOneSecond = new PGInterval("-1 seconds"); - // Update to base makes child invalid relative to Plan Start, and does not affect grandchild - updateOffsetFromAnchor(minusFifteenMinutes, baseId, planId); - assertEquals("Activity Directive " +grandchildId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +childId +".", refresh(grandChildStatus).reasonInvalid); - assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", refresh(childStatus).reasonInvalid); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Create a chain + final int baseActId = merlinHelper.insertActivity(planId, zeroSeconds); + final int[] chainActIds = new int[100]; + chainActIds[0] = insertActivityWithAnchor(planId, minusOneSecond, baseActId, true); + for(int i = 1; i < 100; i++){ + chainActIds[i] = insertActivityWithAnchor(planId, minusOneSecond, chainActIds[i-1], true); + } - // Restoring base clears the warning on child, but not on grandchild - updateOffsetFromAnchor(twentyMinutes, baseId, planId); - assertEquals("Activity Directive " +grandchildId +" has a net negative offset " - + "relative to an end-time anchor on Activity Directive " +childId +".", refresh(grandChildStatus).reasonInvalid); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Get a handle on all the validations + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var chainValidations = new String[100]; + for(int i = 0; i < 100; i++){ + chainValidations[i] = getValidationStatus(planId, chainActIds[i]).reasonInvalid; + } + + // Only the unrelated and base activities are vaid + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(baseValidation.isEmpty()); + for(int i = 0; i < 100; i++){ + assertEquals("Activity Directive " +chainActIds[i] +" has a net negative offset relative to Plan Start.", chainValidations[i]); + } } - // This test is the indirect form of negativeToPlanStartDownChain + /** + * Case: + * Activity A has a positive start offset relative to plan start. + * There is a long chain of activities anchored to the start of A with a start offset of 0. + * Activity B is anchored to the end of the chain with a negative offset relative to the start of the final + * activity in the chain, with a magnitude great enough to put it before plan start. + * Behavior: + * Activity B is invalid. + */ @Test - void onlyGrandchildBecomesInvalid() throws SQLException { - final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); - final PGInterval twentyMinutes = new PGInterval("20 minutes"); - - final int planId = merlinHelper.insertPlan(missionModelId); - final int unrelatedActId = merlinHelper.insertActivity(planId); - - final int parentId = merlinHelper.insertActivity(planId, twentyMinutes.toString()); - final int baseId = insertActivityWithAnchor(planId, twentyMinutes, parentId, true); - final int childId = insertActivityWithAnchor(planId, minusTenMinutes, baseId, true); - final int grandchildId = insertActivityWithAnchor(planId, minusTenMinutes, childId, true); - - // Everything is currently valid regarding Plan Start - final AnchorValidationStatus grandChildStatus = getValidationStatus(planId, grandchildId); - final AnchorValidationStatus childStatus = getValidationStatus(planId, childId); - final AnchorValidationStatus baseStatus = getValidationStatus(planId, baseId); - final AnchorValidationStatus parentStatus = getValidationStatus(planId, parentId); - final AnchorValidationStatus unrelatedStatus = getValidationStatus(planId, unrelatedActId); - assertTrue(grandChildStatus.reasonInvalid.isEmpty()); - assertTrue(childStatus.reasonInvalid.isEmpty()); - assertTrue(baseStatus.reasonInvalid.isEmpty()); - assertTrue(parentStatus.reasonInvalid.isEmpty()); - assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); + void invalidFarDescendant() throws SQLException { + final var tenMinutes = new PGInterval("10 minutes"); + final var minusFifteenMinutes = new PGInterval("-15 minutes"); + final var zeroSeconds = new PGInterval("0 seconds"); - // Update to base makes grandchild invalid relative to Plan Start - updateOffsetFromAnchor(minusTenMinutes, baseId, planId); - assertEquals("Activity Directive " +grandchildId +" has a net negative offset relative to Plan Start.", refresh(grandChildStatus).reasonInvalid); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Create a chain + final int baseActId = merlinHelper.insertActivity(planId, tenMinutes); + final int[] chainActIds = new int[100]; + chainActIds[0] = insertActivityWithAnchor(planId, zeroSeconds, baseActId, true); + for(int i = 1; i < 100; i++){ + chainActIds[i] = insertActivityWithAnchor(planId, zeroSeconds, chainActIds[i-1], true); + } + final int descendantActId = insertActivityWithAnchor(planId, minusFifteenMinutes, chainActIds[99], true); - // Restoring base clears the warnings - updateOffsetFromAnchor(twentyMinutes, baseId, planId); - assertTrue(refresh(grandChildStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(childStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(baseStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(parentStatus).reasonInvalid.isEmpty()); - assertTrue(refresh(unrelatedStatus).reasonInvalid.isEmpty()); + // Get a handle on all the validations + final var unrelatedValidation = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; + final var chainValidations = new String[100]; + for(int i = 0; i < 100; i++){ + chainValidations[i] = getValidationStatus(planId, chainActIds[i]).reasonInvalid; + } + final var descendantValidation = getValidationStatus(planId, descendantActId).reasonInvalid; + + // Everything besides descendant is valid + assertTrue(unrelatedValidation.isEmpty()); + assertTrue(baseValidation.isEmpty()); + for(int i = 0; i < 100; i++){ + assertTrue(chainValidations[i].isEmpty()); + } + assertEquals("Activity Directive " +descendantActId +" has a net negative offset relative to Plan Start.", descendantValidation); } } @Nested class AnchorDeletion { + private final static String reanchorPlanStartStatement = + //language=sql + """ + select hasura.delete_activity_by_pk_reanchor_plan_start(%s, %s, %s) + """; + private final static String reanchorToAnchorStatement = + //language=sql + """ + select hasura.delete_activity_by_pk_reanchor_to_anchor(%s, %s, %s) + """; + private final static String deleteRemainingChainStatement = + //language=sql + """ + select hasura.delete_activity_by_pk_delete_subtree(%s, %s, %s) + """; + + private static Stream nullInputTestArgs() { + return Stream.of( + // Reanchor to Plan Start Cases + Arguments.arguments(reanchorPlanStartStatement, 1), + Arguments.arguments(reanchorPlanStartStatement, 2), + Arguments.arguments(reanchorPlanStartStatement, 3), + // Reanchor to Ascendant Anchor Cases + Arguments.arguments(reanchorToAnchorStatement, 1), + Arguments.arguments(reanchorToAnchorStatement, 2), + Arguments.arguments(reanchorToAnchorStatement, 3), + // Delete Remaining Chain Cases + Arguments.arguments(deleteRemainingChainStatement, 1), + Arguments.arguments(deleteRemainingChainStatement, 2), + Arguments.arguments(deleteRemainingChainStatement, 3) + ); + } + /** + * An activity cannot be removed using a DELETE statement if it has activities anchored to it + */ @Test void cantDeleteActivityWithAnchors() throws SQLException { - final int planId = merlinHelper.insertPlan(missionModelId); final int anchorId = merlinHelper.insertActivity(planId); insertActivityWithAnchor(planId, new PGInterval("0 seconds"), anchorId, true); - try { - merlinHelper.deleteActivityDirective(planId, anchorId); - fail(); - } catch (SQLException ex){ - if(!ex.getMessage().contains( - "update or delete on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\" on table \"activity_directive\"")){ - throw ex; - } - } + final var sqlEx = assertThrows(SQLException.class, () -> merlinHelper.deleteActivityDirective(planId, anchorId)); + assertTrue(sqlEx.getMessage().contains( + "update or delete on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\" on table \"activity_directive\"")); } - // The Hasura functions are defined as 'STRICT', meaning they immediately return NULL if a parameter is NULL rather than raising an exception - @Test - void rebasesDoNotRunOnNullParameters() throws SQLException { - final int planId = merlinHelper.insertPlan(missionModelId); + /** + * The Hasura functions are defined as STRICT. This means that if a NULL value is passed to a parameter, + * the function immediately returns NULL rather than raising an exception. + */ + @ParameterizedTest + @MethodSource("nullInputTestArgs") + void rebasesDoNotRunOnNullParameters(final String sqlStatement, int nullPosition) throws SQLException { final int activityId = merlinHelper.insertActivity(planId); + final var userSession = "'%s'::json".formatted(merlinHelper.admin.session()); + String executingStatement = ""; + + // Since the variables to be supplied to the non-null parameters of "sqlStatement" are non-static, + // we have to apply the formatting here rather than in the argument source + switch (nullPosition) { + case 1 -> executingStatement = sqlStatement.formatted(null, planId, userSession); + case 2 -> executingStatement = sqlStatement.formatted(activityId, null, userSession); + case 3 -> executingStatement = sqlStatement.formatted(activityId, planId, null); + default -> fail("Invalid nullPosition: "+nullPosition); + } - try (final var statement = connection.createStatement()) { - // Reanchor to Plan Start - var results = statement.executeQuery( - //language=sql - """ - select hasura.delete_activity_by_pk_reanchor_plan_start(%d, null, '%s'::json) - """.formatted(activityId, merlinHelper.admin.session())); - if (results.next()) { - fail(); - } + assertFalse(executingStatement.isBlank()); - results = statement.executeQuery( - //language=sql - """ - select hasura.delete_activity_by_pk_reanchor_plan_start(null, %d, '%s'::json) - """.formatted(planId, merlinHelper.admin.session())); + try (final var statement = connection.createStatement()) { + final var results = statement.executeQuery(executingStatement); if (results.next()) { fail(); } + } + } - // Reanchor to ascendant anchor - results = statement.executeQuery( - //language=sql - """ - select hasura.delete_activity_by_pk_reanchor_to_anchor(%d, null, '%s'::json) - """.formatted(activityId, merlinHelper.admin.session())); - if (results.next()) { - fail(); - } + @ParameterizedTest + @ValueSource(strings = {reanchorPlanStartStatement, reanchorToAnchorStatement, deleteRemainingChainStatement}) + void cannotRebaseActivityThatDoesNotExist(String sqlStatement) { + final var executingStatement = sqlStatement.formatted(-1, planId, "'%s'::json".formatted(merlinHelper.admin.session())); - results = statement.executeQuery( - //language=sql - """ - select hasura.delete_activity_by_pk_reanchor_to_anchor(null, %d, '%s'::json) - """.formatted(planId, merlinHelper.admin.session())); - if (results.next()) { - fail(); + final var sqlEx = assertThrows(SQLException.class, () -> { + try(final var statement = connection.createStatement()) { + statement.execute(executingStatement); } + }); + assertTrue(sqlEx.getMessage().contains("Activity Directive -1 does not exist in Plan "+planId)); + } - // Delete Remaining Chain - results = statement.executeQuery( - //language=sql - """ - select hasura.delete_activity_by_pk_delete_subtree(%d, null, '%s'::json) - """.formatted(activityId, merlinHelper.admin.session())); - if (results.next()) { - fail(); - } - results = statement.executeQuery( - //language=sql - """ - select hasura.delete_activity_by_pk_delete_subtree(null, %d, '%s'::json) - """.formatted(planId, merlinHelper.admin.session())); - if (results.next()) { - fail(); - } + private int insertUnanchoredActivities(int planId) throws SQLException { + final PGInterval oneDay = new PGInterval("1 day"); + int lastInsertedId = merlinHelper.insertActivity(planId); + for(int i = 0; i < 10; i++){ + lastInsertedId = insertActivityWithAnchor(planId, oneDay, lastInsertedId, true); } + return lastInsertedId; } - @Test - void cannotRebaseActivityThatDoesNotExist() throws SQLException{ - final int planId = merlinHelper.insertPlan(missionModelId); - - try(final var statement = connection.createStatement()) { - statement.execute( - //language=sql - """ - select hasura.delete_activity_by_pk_reanchor_plan_start(-1, %d, '%s'::json) - """.formatted(planId, merlinHelper.admin.session())); - fail(); - } catch (SQLException ex){ - if(!ex.getMessage().contains("Activity Directive -1 does not exist in Plan "+planId)){ - throw ex; - } - } + private void insertChains(int chain1BaseId, int chain2BaseId, int chain3BaseId) throws SQLException { + final PGInterval zeroSeconds = new PGInterval("0 seconds"); - try(final var statement = connection.createStatement()) { - statement.execute( - //language=sql - """ - select hasura.delete_activity_by_pk_reanchor_to_anchor(-1, %d, '%s'::json) - """.formatted(planId, merlinHelper.admin.session())); - fail(); - } catch (SQLException ex){ - if(!ex.getMessage().contains("Activity Directive -1 does not exist in Plan "+planId)){ - throw ex; - } - } + int mostRecentChain1Id = chain1BaseId; + int mostRecentChain2Id = chain2BaseId; + int mostRecentChain3Id = chain3BaseId; - try(final var statement = connection.createStatement()) { - statement.execute( - //language=sql - """ - select hasura.delete_activity_by_pk_delete_subtree(-1, %d, '%s'::json) - """.formatted(planId, merlinHelper.admin.session())); - fail(); - } catch (SQLException ex){ - if(!ex.getMessage().contains("Activity Directive -1 does not exist in Plan "+planId)){ - throw ex; - } + for(int i = 0; i < 100; i++) { + mostRecentChain1Id = insertActivityWithAnchor(planId, zeroSeconds, mostRecentChain1Id, true); + mostRecentChain2Id = insertActivityWithAnchor(planId, zeroSeconds, mostRecentChain2Id, false); + mostRecentChain3Id = insertActivityWithAnchor(planId, zeroSeconds, mostRecentChain3Id, (i & 1) == 0); // alternates true and false } } @Test void rebaseToAscendantAnchor() throws SQLException{ - final int planId = merlinHelper.insertPlan(missionModelId); - final PGInterval oneDay = new PGInterval("1 day"); final PGInterval minusTwoDays = new PGInterval("-2 days"); final PGInterval minusFourDays = new PGInterval("-4 days"); + final PGInterval zeroSeconds = new PGInterval("0 seconds"); - int lastInsertedId = merlinHelper.insertActivity(planId); - for(int i = 0; i<10; i++){ - lastInsertedId = insertActivityWithAnchor(planId, oneDay, lastInsertedId, true); - } - + // Set-up: add a bunch of unanchored activities that won't be touched by the delete + final var parentId = insertUnanchoredActivities(planId); final var untouchedActivities = getActivities(planId); - final int baseId = insertActivityWithAnchor(planId, minusTwoDays, lastInsertedId, true); + // Add the activity that will be deleted + final int baseId = insertActivityWithAnchor(planId, minusTwoDays, parentId, true); + /* Add three chains of activities, each anchored to the base activity + * - In chain 1, all anchors are "start time anchors" + * - In chain 2, all anchors are "end time anchors" + * - In chain 3, anchors alternate between being "start time" and "end time" + */ final int chain1BaseId = insertActivityWithAnchor(planId, minusTwoDays, baseId, true); final int chain2BaseId = insertActivityWithAnchor(planId, minusTwoDays, baseId, false); final int chain3BaseId = insertActivityWithAnchor(planId, minusTwoDays, baseId, true); - int mostRecentChain1Id = chain1BaseId; - int mostRecentChain2Id = chain2BaseId; - int mostRecentChain3Id = chain3BaseId; - - for(int i = 0; i < 100; i++) { - mostRecentChain1Id = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), mostRecentChain1Id, true); - mostRecentChain2Id = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), mostRecentChain2Id, false); - mostRecentChain3Id = insertActivityWithAnchor(planId, new PGInterval("0 seconds"), mostRecentChain3Id, (i & 1) == 0); // alternates true and false - } + insertChains(chain1BaseId, chain2BaseId, chain3BaseId); assertEquals(304+untouchedActivities.size(), getActivities(planId).size()); + // Delete the Base activity using the "reanchor chain to this activity's anchor" strategy try(final var statement = connection.createStatement()) { statement.execute( //language=sql @@ -1020,57 +984,57 @@ void rebaseToAscendantAnchor() throws SQLException{ """.formatted(baseId, planId, merlinHelper.admin.session())); } + // Only one activity should've been deleted final var remainingActivities = getActivities(planId); - assertEquals(314, remainingActivities.size()); + assertEquals(303+untouchedActivities.size(), remainingActivities.size()); + // The unanchored activities should be untouched for(int i = 0; i < untouchedActivities.size(); i++){ assertActivityEquals(untouchedActivities.get(i), remainingActivities.get(i)); } + // The parent activity has inherited the chains, with the start offsets being appropriately adjusted + // so that the chains still start at the same time assertEquals(minusFourDays, getActivity(planId, chain1BaseId).startOffset); - assertEquals(""+lastInsertedId, getActivity(planId,chain1BaseId).anchorId); + assertEquals(parentId, getActivity(planId,chain1BaseId).anchorId); assertEquals(minusFourDays, getActivity(planId, chain2BaseId).startOffset); - assertEquals(""+lastInsertedId, getActivity(planId,chain2BaseId).anchorId); + assertEquals(parentId, getActivity(planId,chain2BaseId).anchorId); assertEquals(minusFourDays, getActivity(planId, chain3BaseId).startOffset); - assertEquals(""+lastInsertedId, getActivity(planId,chain3BaseId).anchorId); + assertEquals(parentId, getActivity(planId,chain3BaseId).anchorId); - for(int i = untouchedActivities.size()+3; i Date: Fri, 8 May 2026 10:18:38 -0700 Subject: [PATCH 3/3] update comments on a few db tests, add new test for grandchild with negative net offset --- .../nasa/jpl/aerie/database/AnchorTests.java | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java index 2255fc7ace..88d7caa901 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/AnchorTests.java @@ -492,7 +492,7 @@ void onlyInvalidParent(String childInterval) throws SQLException { final var baseValidation = getValidationStatus(planId, baseActId).reasonInvalid; final var childValidation = getValidationStatus(planId, childActId).reasonInvalid; - // Only Base should both have a warning message + // Only Base should have a warning message assertTrue(unrelatedValidation.isEmpty()); assertTrue(parentValidation.isEmpty()); assertEquals("Activity Directive " +baseActId +" has a net negative offset " @@ -601,7 +601,7 @@ void invalidMessageClearedUponResolution() throws SQLException { assertTrue(unrelatedStatus.reasonInvalid.isEmpty()); assertEquals("Activity Directive " +activityId +" has a net negative offset relative to Plan Start.", activityStatus.reasonInvalid); - // Update activity to have a positive offset relative to the end time of parent, making it valid + // Update activity to have a positive offset relative to the plan start, making it valid updateOffsetFromAnchor(tenMinutes, activityId, planId); // The warning message has been cleared @@ -653,6 +653,36 @@ void indirectlyInvalidChild() throws SQLException { assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", childStatus); } + /** + * Case: + * Activity A has a negative start offset relative to plan start. + * Activity B and Activity C each have positive start offsets, but not enough to put the chain back within plan bounds. + * Result: + * Activity A, B, and C are all invalid. + */ + @Test + void recursivelyInvalidPositiveOffsetDescendant() throws SQLException { + final PGInterval minusTenMinutes = new PGInterval("-10 minutes"); + final PGInterval fiveMinutes = new PGInterval("5 minutes"); + final PGInterval fourMinutes = new PGInterval("4 minutes"); + + final int parentId = merlinHelper.insertActivity(planId, minusTenMinutes); + final int childId = insertActivityWithAnchor(planId, fiveMinutes, parentId, true); + final int grandchildId = insertActivityWithAnchor(planId, fourMinutes, childId, true); + + // Get a handle on their validation statuses + final var unrelatedStatus = getValidationStatus(planId, unrelatedActId).reasonInvalid; + final var parentStatus = getValidationStatus(planId, parentId).reasonInvalid; + final var childStatus = getValidationStatus(planId, childId).reasonInvalid; + final var grandchildStatus = getValidationStatus(planId, grandchildId).reasonInvalid; + + // Only the unrelated activity is valid, as the net start offsets are "-10 minutes", "-5 minutes", and "-1 minute" + assertTrue(unrelatedStatus.isEmpty()); + assertEquals("Activity Directive " +parentId +" has a net negative offset relative to Plan Start.", parentStatus); + assertEquals("Activity Directive " +childId +" has a net negative offset relative to Plan Start.", childStatus); + assertEquals("Activity Directive " +grandchildId +" has a net negative offset relative to Plan Start.", grandchildStatus); + } + /** * Case: * Activity A has a negative start offset relative to plan start.