Skip to content

Commit d890863

Browse files
feat: Add hasNoImpact() to ConstraintVerifier assertions (#1909)
Co-authored-by: Lukáš Petrovický <[email protected]>
1 parent 9618337 commit d890863

File tree

3 files changed

+169
-0
lines changed

3 files changed

+169
-0
lines changed

test/src/main/java/ai/timefold/solver/test/api/score/stream/SingleConstraintAssertion.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,25 @@ SingleConstraintAssertion justifiesWithExactly(@Nullable String message,
9797
@NonNull
9898
SingleConstraintAssertion indictsWithExactly(@Nullable String message, @NonNull Object @NonNull... indictments);
9999

100+
/**
101+
* Asserts that the {@link Constraint} being tested, given a set of facts, results in no impact on the score.
102+
* <p>
103+
* This is equivalent to checking that there are neither penalties nor rewards.
104+
*
105+
* @throws AssertionError when there is any impact (penalty or reward)
106+
*/
107+
default void hasNoImpact() {
108+
hasNoImpact(null);
109+
}
110+
111+
/**
112+
* As defined by {@link #hasNoImpact()}.
113+
*
114+
* @param message description of the scenario being asserted
115+
* @throws AssertionError when there is any impact (penalty or reward)
116+
*/
117+
void hasNoImpact(@Nullable String message);
118+
100119
/**
101120
* Asserts that the {@link Constraint} being tested, given a set of facts, results in a specific penalty.
102121
* <p>

test/src/main/java/ai/timefold/solver/test/impl/score/stream/AbstractSingleConstraintAssertion.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,12 @@ public void rewardsWithLessThan(@Nullable String message, @NonNull BigDecimal ma
271271
assertLessThanImpact(ScoreImpactType.REWARD, matchWeightTotal, message);
272272
}
273273

274+
@Override
275+
public void hasNoImpact(@Nullable String message) {
276+
ensureInitialized();
277+
assertNoImpact(message);
278+
}
279+
274280
private static void validateLessThanMatchWeighTotal(Number matchWeightTotal) {
275281
if (matchWeightTotal.doubleValue() < 1) {
276282
throw new IllegalArgumentException("The matchWeightTotal (%s) must be greater than 0.".formatted(matchWeightTotal));
@@ -365,6 +371,24 @@ private void assertLessThanImpact(ScoreImpactType scoreImpactType, Number matchW
365371
throw new AssertionError(assertionMessage);
366372
}
367373

374+
private void assertNoImpact(String message) {
375+
var deducedImpacts = deduceImpact();
376+
var impact = deducedImpacts.key();
377+
var negatedImpact = deducedImpacts.value();
378+
var zeroScore = scoreDefinition.getZeroScore();
379+
var zero = zeroScore.toLevelNumbers()[0];
380+
var equalityPredicate = NumberEqualityUtil.getEqualityPredicate(scoreDefinition, zero);
381+
382+
// Check if both the impact and negated impact are zero
383+
if (equalityPredicate.test(zero, impact) && equalityPredicate.test(zero, negatedImpact)) {
384+
return;
385+
}
386+
387+
var constraintId = constraint.getConstraintRef().constraintId();
388+
var assertionMessage = buildNoImpactAssertionErrorMessage(impact, constraintId, message);
389+
throw new AssertionError(assertionMessage);
390+
}
391+
368392
private void assertJustification(String message, boolean completeValidation, ConstraintJustification... justifications) {
369393
// Valid empty comparison
370394
var emptyJustifications = justifications == null || justifications.length == 0;
@@ -714,6 +738,21 @@ private static String buildAssertionErrorMessage(String type, String constraintI
714738
return String.format(preformattedMessage.toString(), params.toArray());
715739
}
716740

741+
private String buildNoImpactAssertionErrorMessage(Number actualImpact, String constraintId, String message) {
742+
var expectation = message != null ? message : "Broken expectation.";
743+
return """
744+
%s
745+
Constraint: %s
746+
Expected: no impact
747+
Actual impact: %s (%s)
748+
749+
%s""".formatted(
750+
expectation,
751+
constraintId,
752+
actualImpact, actualImpact.getClass(),
753+
DefaultScoreExplanation.explainScore(actualScore, constraintMatchTotalCollection, indictmentCollection));
754+
}
755+
717756
private static String getImpactTypeLabel(ScoreImpactType scoreImpactType) {
718757
if (scoreImpactType == ScoreImpactType.PENALTY) {
719758
return "penalty";

test/src/test/java/ai/timefold/solver/test/api/score/stream/SingleConstraintAssertionTest.java

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,117 @@ void rewardsButDoesNotPenalize() {
193193
.hasMessageContaining("Expected penalty");
194194
}
195195

196+
@Test
197+
void hasNoImpact() {
198+
var solution = TestdataConstraintVerifierSolution.generateSolution(2, 3);
199+
200+
// Test with no entities - should have no impact
201+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::penalizeEveryEntity)
202+
.given()
203+
.hasNoImpact("There should be no impact")).doesNotThrowAnyException();
204+
205+
// Test without custom message - no entities
206+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::penalizeEveryEntity)
207+
.given()
208+
.hasNoImpact()).doesNotThrowAnyException();
209+
210+
// Test with entities that trigger penalties - should fail
211+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::penalizeEveryEntity)
212+
.given(solution.getEntityList().toArray())
213+
.hasNoImpact("There should be no impact"))
214+
.isInstanceOf(AssertionError.class)
215+
.hasMessageContaining("There should be no impact")
216+
.hasMessageContaining("Constraint")
217+
.hasMessageContaining("Expected")
218+
.hasMessageContaining("no impact")
219+
.hasMessageContaining("Actual impact");
220+
221+
// Test with entities that trigger penalties - without custom message
222+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::penalizeEveryEntity)
223+
.given(solution.getEntityList().toArray())
224+
.hasNoImpact())
225+
.hasMessageContaining("Broken expectation")
226+
.hasMessageContaining("Constraint")
227+
.hasMessageContaining("Expected")
228+
.hasMessageContaining("no impact")
229+
.hasMessageContaining("Actual impact");
230+
231+
// Test with entities that trigger rewards - should fail
232+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::rewardEveryEntity)
233+
.given(solution.getEntityList().toArray())
234+
.hasNoImpact("There should be no impact"))
235+
.isInstanceOf(AssertionError.class)
236+
.hasMessageContaining("There should be no impact")
237+
.hasMessageContaining("Constraint")
238+
.hasMessageContaining("Expected")
239+
.hasMessageContaining("no impact")
240+
.hasMessageContaining("Actual impact");
241+
242+
// Test with entities that trigger rewards - without custom message
243+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::rewardEveryEntity)
244+
.given(solution.getEntityList().toArray())
245+
.hasNoImpact())
246+
.hasMessageContaining("Broken expectation")
247+
.hasMessageContaining("Constraint")
248+
.hasMessageContaining("Expected")
249+
.hasMessageContaining("no impact")
250+
.hasMessageContaining("Actual impact");
251+
}
252+
253+
@Test
254+
void hasNoImpactWithMixedConstraint() {
255+
// Test mixed constraint with no entities - should have no impact
256+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::impactEveryEntity)
257+
.given()
258+
.hasNoImpact()).doesNotThrowAnyException();
259+
260+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::impactEveryEntity)
261+
.given()
262+
.hasNoImpact("There should be no impact")).doesNotThrowAnyException();
263+
264+
// Test mixed constraint with entities that cause penalties - should fail
265+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::impactEveryEntity)
266+
.given(new TestdataConstraintVerifierFirstEntity("A", new TestdataValue()))
267+
.hasNoImpact("There should be no impact"))
268+
.isInstanceOf(AssertionError.class)
269+
.hasMessageContaining("There should be no impact")
270+
.hasMessageContaining("Constraint")
271+
.hasMessageContaining("Expected")
272+
.hasMessageContaining("no impact")
273+
.hasMessageContaining("Actual impact");
274+
275+
// Test mixed constraint with entities that cause penalties - without custom message
276+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::impactEveryEntity)
277+
.given(new TestdataConstraintVerifierFirstEntity("A", new TestdataValue()))
278+
.hasNoImpact())
279+
.hasMessageContaining("Broken expectation")
280+
.hasMessageContaining("Constraint")
281+
.hasMessageContaining("Expected")
282+
.hasMessageContaining("no impact")
283+
.hasMessageContaining("Actual impact");
284+
285+
// Test mixed constraint with entities that cause rewards - should fail
286+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::impactEveryEntity)
287+
.given(new TestdataConstraintVerifierFirstEntity("B", new TestdataValue()))
288+
.hasNoImpact("There should be no impact"))
289+
.isInstanceOf(AssertionError.class)
290+
.hasMessageContaining("There should be no impact")
291+
.hasMessageContaining("Constraint")
292+
.hasMessageContaining("Expected")
293+
.hasMessageContaining("no impact")
294+
.hasMessageContaining("Actual impact");
295+
296+
// Test mixed constraint with entities that cause rewards - without custom message
297+
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::impactEveryEntity)
298+
.given(new TestdataConstraintVerifierFirstEntity("B", new TestdataValue()))
299+
.hasNoImpact())
300+
.hasMessageContaining("Broken expectation")
301+
.hasMessageContaining("Constraint")
302+
.hasMessageContaining("Expected")
303+
.hasMessageContaining("no impact")
304+
.hasMessageContaining("Actual impact");
305+
}
306+
196307
@Test
197308
void impacts() {
198309
assertThatCode(() -> constraintVerifier.verifyThat(TestdataConstraintVerifierConstraintProvider::impactEveryEntity)

0 commit comments

Comments
 (0)