diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java index 599157f4..42565539 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java @@ -780,7 +780,7 @@ public class QInstanceValidator { if(assertCondition(StringUtils.hasContent(association.getName()), "missing a name for an Association on table " + table.getName())) { - String messageSuffix = " for Association " + association.getName() + " on table " + table.getName(); + String messageSuffix = " for Association " + association.getName() + " on table " + table.getName(); boolean recognizedTable = false; if(assertCondition(StringUtils.hasContent(association.getAssociatedTableName()), "missing associatedTableName" + messageSuffix)) { @@ -988,7 +988,15 @@ public class QInstanceValidator @SuppressWarnings("unchecked") Class> behaviorClass = (Class>) fieldBehavior.getClass(); - errors.addAll(fieldBehavior.validateBehaviorConfiguration(table, field)); + List behaviorErrors = fieldBehavior.validateBehaviorConfiguration(table, field); + if(behaviorErrors != null) + { + String prefixMinusTrailingSpace = prefix.replaceFirst(" *$", ""); + for(String behaviorError : behaviorErrors) + { + errors.add(prefixMinusTrailingSpace + ": " + behaviorClass.getSimpleName() + ": " + behaviorError); + } + } if(!fieldBehavior.allowMultipleBehaviorsOfThisType()) { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueRangeBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueRangeBehavior.java new file mode 100644 index 00000000..aed2317a --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueRangeBehavior.java @@ -0,0 +1,477 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.metadata.fields; + + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; + + +/******************************************************************************* + ** Validate the min & max value for numeric fields. + ** + ** For each min & max, there are 4 possible settings: + ** - value - the number that is compared. + ** - allowEqualTo - defaults to true. controls if < (>) or ≤ (≥) + ** - behavior - defaults to ERROR. optionally can be "CLIP" instead. + ** - clipAmount - if clipping, and not allowing equalTo, how much off the limit + ** value should be added or subtracted. Defaults to 1. + ** + ** Convenient `withMin()` and `withMax()` methods exist for setting all 4 + ** properties for each of min or max. Else, fluent-setters are recommended. + *******************************************************************************/ +public class ValueRangeBehavior implements FieldBehavior +{ + /*************************************************************************** + ** + ***************************************************************************/ + public enum Behavior + { + ERROR, + CLIP + } + + + + private Number minValue; + private boolean minAllowEqualTo = true; + private Behavior minBehavior = Behavior.ERROR; + private BigDecimal minClipAmount = BigDecimal.ONE; + + private Number maxValue; + private boolean maxAllowEqualTo = true; + private Behavior maxBehavior = Behavior.ERROR; + private BigDecimal maxClipAmount = BigDecimal.ONE; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public ValueRangeBehavior() + { + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public ValueRangeBehavior getDefault() + { + return null; + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field) + { + BigDecimal minLimitBigDecimal = minValue == null ? null : new BigDecimal(minValue.toString()); + String minLimitString = minValue == null ? null : minValue.toString(); + + BigDecimal maxLimitBigDecimal = maxValue == null ? null : new BigDecimal(maxValue.toString()); + String maxLimitString = maxValue == null ? null : maxValue.toString(); + + for(QRecord record : recordList) + { + BigDecimal recordValue = record.getValueBigDecimal(field.getName()); + if(recordValue != null) + { + if(minLimitBigDecimal != null) + { + int compare = recordValue.compareTo(minLimitBigDecimal); + if(compare < 0 || (compare == 0 && !minAllowEqualTo)) + { + if(this.minBehavior == Behavior.ERROR) + { + String operator = minAllowEqualTo ? "" : "greater than "; + record.addError(new BadInputStatusMessage("The value for " + field.getLabel() + " is too small (minimum allowed value is " + operator + minLimitString + ")")); + } + else if(this.minBehavior == Behavior.CLIP) + { + if(minAllowEqualTo) + { + record.setValue(field.getName(), minLimitBigDecimal); + } + else + { + record.setValue(field.getName(), minLimitBigDecimal.add(minClipAmount)); + } + } + } + } + + if(maxLimitBigDecimal != null) + { + int compare = recordValue.compareTo(maxLimitBigDecimal); + if(compare > 0 || (compare == 0 && !maxAllowEqualTo)) + { + if(this.maxBehavior == Behavior.ERROR) + { + String operator = maxAllowEqualTo ? "" : "less than "; + record.addError(new BadInputStatusMessage("The value for " + field.getLabel() + " is too large (maximum allowed value is " + operator + maxLimitString + ")")); + } + else if(this.maxBehavior == Behavior.CLIP) + { + if(maxAllowEqualTo) + { + record.setValue(field.getName(), maxLimitBigDecimal); + } + else + { + record.setValue(field.getName(), maxLimitBigDecimal.subtract(maxClipAmount)); + } + } + } + } + + } + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public boolean allowMultipleBehaviorsOfThisType() + { + return (false); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List validateBehaviorConfiguration(QTableMetaData tableMetaData, QFieldMetaData fieldMetaData) + { + List errors = new ArrayList<>(); + + if(minValue == null && maxValue == null) + { + errors.add("Either minValue or maxValue (or both) must be set."); + } + + if(minValue != null && maxValue != null && new BigDecimal(minValue.toString()).compareTo(new BigDecimal(maxValue.toString())) > 0) + { + errors.add("minValue must be >= maxValue."); + } + + if(fieldMetaData != null && fieldMetaData.getType() != null && !fieldMetaData.getType().isNumeric()) + { + errors.add("can only be applied to a numeric type field."); + } + + return (errors); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public ValueRangeBehavior withMin(Number value, boolean allowEqualTo, Behavior behavior, BigDecimal clipAmount) + { + setMinValue(value); + setMinAllowEqualTo(allowEqualTo); + setMinBehavior(behavior); + setMinClipAmount(clipAmount); + return (this); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + public ValueRangeBehavior withMax(Number value, boolean allowEqualTo, Behavior behavior, BigDecimal clipAmount) + { + setMaxValue(value); + setMaxAllowEqualTo(allowEqualTo); + setMaxBehavior(behavior); + setMaxClipAmount(clipAmount); + return (this); + } + + + + /******************************************************************************* + ** Getter for minValue + *******************************************************************************/ + public Number getMinValue() + { + return (this.minValue); + } + + + + /******************************************************************************* + ** Setter for minValue + *******************************************************************************/ + public void setMinValue(Number minValue) + { + this.minValue = minValue; + } + + + + /******************************************************************************* + ** Fluent setter for minValue + *******************************************************************************/ + public ValueRangeBehavior withMinValue(Number minValue) + { + this.minValue = minValue; + return (this); + } + + + + /******************************************************************************* + ** Getter for maxValue + *******************************************************************************/ + public Number getMaxValue() + { + return (this.maxValue); + } + + + + /******************************************************************************* + ** Setter for maxValue + *******************************************************************************/ + public void setMaxValue(Number maxValue) + { + this.maxValue = maxValue; + } + + + + /******************************************************************************* + ** Fluent setter for maxValue + *******************************************************************************/ + public ValueRangeBehavior withMaxValue(Number maxValue) + { + this.maxValue = maxValue; + return (this); + } + + + + /******************************************************************************* + ** Getter for minAllowEqualTo + *******************************************************************************/ + public boolean getMinAllowEqualTo() + { + return (this.minAllowEqualTo); + } + + + + /******************************************************************************* + ** Setter for minAllowEqualTo + *******************************************************************************/ + public void setMinAllowEqualTo(boolean minAllowEqualTo) + { + this.minAllowEqualTo = minAllowEqualTo; + } + + + + /******************************************************************************* + ** Fluent setter for minAllowEqualTo + *******************************************************************************/ + public ValueRangeBehavior withMinAllowEqualTo(boolean minAllowEqualTo) + { + this.minAllowEqualTo = minAllowEqualTo; + return (this); + } + + + + /******************************************************************************* + ** Getter for maxAllowEqualTo + *******************************************************************************/ + public boolean getMaxAllowEqualTo() + { + return (this.maxAllowEqualTo); + } + + + + /******************************************************************************* + ** Setter for maxAllowEqualTo + *******************************************************************************/ + public void setMaxAllowEqualTo(boolean maxAllowEqualTo) + { + this.maxAllowEqualTo = maxAllowEqualTo; + } + + + + /******************************************************************************* + ** Fluent setter for maxAllowEqualTo + *******************************************************************************/ + public ValueRangeBehavior withMaxAllowEqualTo(boolean maxAllowEqualTo) + { + this.maxAllowEqualTo = maxAllowEqualTo; + return (this); + } + + + + /******************************************************************************* + ** Getter for minBehavior + *******************************************************************************/ + public Behavior getMinBehavior() + { + return (this.minBehavior); + } + + + + /******************************************************************************* + ** Setter for minBehavior + *******************************************************************************/ + public void setMinBehavior(Behavior minBehavior) + { + this.minBehavior = minBehavior; + } + + + + /******************************************************************************* + ** Fluent setter for minBehavior + *******************************************************************************/ + public ValueRangeBehavior withMinBehavior(Behavior minBehavior) + { + this.minBehavior = minBehavior; + return (this); + } + + + + /******************************************************************************* + ** Getter for maxBehavior + *******************************************************************************/ + public Behavior getMaxBehavior() + { + return (this.maxBehavior); + } + + + + /******************************************************************************* + ** Setter for maxBehavior + *******************************************************************************/ + public void setMaxBehavior(Behavior maxBehavior) + { + this.maxBehavior = maxBehavior; + } + + + + /******************************************************************************* + ** Fluent setter for maxBehavior + *******************************************************************************/ + public ValueRangeBehavior withMaxBehavior(Behavior maxBehavior) + { + this.maxBehavior = maxBehavior; + return (this); + } + + + + /******************************************************************************* + ** Getter for minClipAmount + *******************************************************************************/ + public BigDecimal getMinClipAmount() + { + return (this.minClipAmount); + } + + + + /******************************************************************************* + ** Setter for minClipAmount + *******************************************************************************/ + public void setMinClipAmount(BigDecimal minClipAmount) + { + this.minClipAmount = minClipAmount; + } + + + + /******************************************************************************* + ** Fluent setter for minClipAmount + *******************************************************************************/ + public ValueRangeBehavior withMinClipAmount(BigDecimal minClipAmount) + { + this.minClipAmount = minClipAmount; + return (this); + } + + + + /******************************************************************************* + ** Getter for maxClipAmount + *******************************************************************************/ + public BigDecimal getMaxClipAmount() + { + return (this.maxClipAmount); + } + + + + /******************************************************************************* + ** Setter for maxClipAmount + *******************************************************************************/ + public void setMaxClipAmount(BigDecimal maxClipAmount) + { + this.maxClipAmount = maxClipAmount; + } + + + + /******************************************************************************* + ** Fluent setter for maxClipAmount + *******************************************************************************/ + public ValueRangeBehavior withMaxClipAmount(BigDecimal maxClipAmount) + { + this.maxClipAmount = maxClipAmount; + return (this); + } + +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java index 81e506ee..c09d1ac8 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java @@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.DateTimeDisplayValue import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; @@ -1926,6 +1927,20 @@ public class QInstanceValidatorTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldBehaviorsWithTheirOwnValidateMethods() + { + Function fieldExtractor = qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).getField("id"); + assertValidationFailureReasons((qInstance -> fieldExtractor.apply(qInstance).withBehavior(new ValueRangeBehavior())), + "Field id in table person: ValueRangeBehavior: Either minValue or maxValue (or both) must be set."); + + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueRangeBehaviorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueRangeBehaviorTest.java new file mode 100644 index 00000000..9a1c0362 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueRangeBehaviorTest.java @@ -0,0 +1,144 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.metadata.fields; + + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior.Behavior; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionAssert; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** Unit test for ValueOutsideOfRangeBehavior + *******************************************************************************/ +class ValueRangeBehaviorTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + + table.getField("noOfShoes").withBehavior(new ValueRangeBehavior().withMinValue(0)); + table.getField("cost").withBehavior(new ValueRangeBehavior().withMaxValue(new BigDecimal("3.50"))); + table.getField("price").withBehavior(new ValueRangeBehavior() + .withMin(BigDecimal.ZERO, false, Behavior.CLIP, new BigDecimal(".01")) + .withMaxValue(new BigDecimal(100)).withMaxAllowEqualTo(false)); + + List recordList = List.of( + new QRecord().withValue("id", 1).withValue("noOfShoes", -1).withValue("cost", new BigDecimal("3.50")).withValue("price", new BigDecimal(-1)), + new QRecord().withValue("id", 2).withValue("noOfShoes", 0).withValue("cost", new BigDecimal("3.51")).withValue("price", new BigDecimal(200)), + new QRecord().withValue("id", 3).withValue("noOfShoes", 1).withValue("cost", new BigDecimal("3.50")).withValue("price", new BigDecimal("99.99")) + ); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.INSERT, qInstance, table, recordList, null); + + { + QRecord record = getRecordById(recordList, 1); + assertEquals(-1, record.getValueInteger("noOfShoes")); // error (but didn't change value) + assertEquals(new BigDecimal("3.50"), record.getValueBigDecimal("cost")); // all okay + assertEquals(new BigDecimal("0.01"), record.getValueBigDecimal("price")); // got clipped + assertThat(record.getErrors()) + .hasSize(1) + .anyMatch(e -> e.getMessage().equals("The value for No Of Shoes is too small (minimum allowed value is 0)")); + } + + { + QRecord record = getRecordById(recordList, 2); + assertEquals(0, record.getValueInteger("noOfShoes")); // all ok + assertEquals(new BigDecimal("3.51"), record.getValueBigDecimal("cost")); // error (but didn't change value) + assertEquals(new BigDecimal(200), record.getValueBigDecimal("price")); // error (but didn't change value) + assertThat(record.getErrors()) + .hasSize(2) + .anyMatch(e -> e.getMessage().equals("The value for Cost is too large (maximum allowed value is 3.50)")) + .anyMatch(e -> e.getMessage().equals("The value for Price is too large (maximum allowed value is less than 100)")); + } + + { + QRecord record = getRecordById(recordList, 3); + assertEquals(1, record.getValueInteger("noOfShoes")); // all ok + assertEquals(new BigDecimal("3.50"), record.getValueBigDecimal("cost")); // all ok + assertEquals(new BigDecimal("99.99"), record.getValueBigDecimal("price")); // all ok + assertThat(record.getErrors()).isNullOrEmpty(); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidation() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + QFieldMetaData noOfShoesField = table.getField("noOfShoes"); + QFieldMetaData firstNameField = table.getField("firstName"); + + CollectionAssert.assertThat(new ValueRangeBehavior().validateBehaviorConfiguration(table, noOfShoesField)) + .matchesAll(List.of("Either minValue or maxValue (or both) must be set."), Objects::equals); + + CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(0).validateBehaviorConfiguration(table, noOfShoesField)).isNullOrEmpty(); + CollectionAssert.assertThat(new ValueRangeBehavior().withMaxValue(100).validateBehaviorConfiguration(table, noOfShoesField)).isNullOrEmpty(); + CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(0).withMaxValue(100).validateBehaviorConfiguration(table, noOfShoesField)).isNullOrEmpty(); + + CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(1).withMaxValue(0).validateBehaviorConfiguration(table, noOfShoesField)) + .matchesAll(List.of("minValue must be >= maxValue."), Objects::equals); + + CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(1).validateBehaviorConfiguration(table, firstNameField)) + .matchesAll(List.of("can only be applied to a numeric type field."), Objects::equals); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QRecord getRecordById(List recordList, Integer id) + { + Optional recordOpt = recordList.stream().filter(r -> r.getValueInteger("id").equals(id)).findFirst(); + if(recordOpt.isEmpty()) + { + fail("Didn't find record with id=" + id); + } + return (recordOpt.get()); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/CollectionAssert.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/CollectionAssert.java new file mode 100644 index 00000000..d5fc09a9 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/CollectionAssert.java @@ -0,0 +1,184 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.utils; + + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiFunction; +import org.assertj.core.api.AbstractAssert; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** assertions against collections + *******************************************************************************/ +public class CollectionAssert extends AbstractAssert, Collection> +{ + /*************************************************************************** + ** + ***************************************************************************/ + protected CollectionAssert(Collection actual, Class selfType) + { + super(actual, selfType); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public static CollectionAssert assertThat(Collection actualCollection) + { + return (new CollectionAssert<>(actualCollection, CollectionAssert.class)); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public CollectionAssert matchesAllowingExpectedToHaveMore(Collection expected, BiFunction predicate) + { + if(actual == null && expected != null) + { + fail("Actual collection was null, but expected collection was not-null"); + return (this); + } + else if(actual != null && expected == null) + { + fail("Actual collection was not null, but expected collection was null"); + return (this); + } + else if(actual == null && expected == null) + { + return (this); + } + + assertTrue(actual.size() >= expected.size(), "Actual collection size [" + actual.size() + "] should be >= expected collection size [" + expected.size() + "]"); + + matchElements(expected, predicate); + + return (this); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + private void matchElements(Collection expected, BiFunction predicate) + { + List nonMatchingExpectedIndexes = new ArrayList<>(); + Set matchedActualIndexes = new HashSet<>(); + + List expectedList = new ArrayList<>(expected); + List actualList = new ArrayList<>(actual); + + for(int eIndex = 0; eIndex < expectedList.size(); eIndex++) + { + E e = expectedList.get(eIndex); + + boolean matchedThieE = false; + + for(int aIndex = 0; aIndex < actualList.size(); aIndex++) + { + A a = actualList.get(aIndex); + if(!matchedThieE && !matchedActualIndexes.contains(aIndex)) // don't re-check an already-matched item + { + if(predicate.apply(e, a)) + { + matchedActualIndexes.add(aIndex); + matchedThieE = true; + } + } + } + + if(!matchedThieE) + { + nonMatchingExpectedIndexes.add(eIndex); + } + } + + assertTrue(nonMatchingExpectedIndexes.isEmpty(), "Did not find a match for indexes " + nonMatchingExpectedIndexes + "\n from expected collection: " + expected + "\n in actual collection: " + actual); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public CollectionAssert matchesAll(Collection expected, BiFunction predicate) + { + if(actual == null && expected != null) + { + fail("Actual collection was null, but expected collection was not-null"); + return (this); + } + else if(actual != null && expected == null) + { + fail("Actual collection was not null, but expected collection was null"); + return (this); + } + else if(actual == null && expected == null) + { + return (this); + } + + assertEquals(expected.size(), actual.size(), "Expected size of collections"); + + matchElements(expected, predicate); + + return (this); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public CollectionAssert isNullOrEmpty() + { + if(actual != null) + { + assertEquals(0, actual.size(), "Expected collection to be null or empty"); + } + return (this); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public CollectionAssert isEmpty() + { + assertEquals(0, actual.size(), "Expected collection to be empty"); + return (this); + } + +}