diff --git a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java index 150a6559d7..631493683a 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java @@ -562,6 +562,17 @@ public void setMonitoringConfig(@Nullable MonitoringConfig monitoringConfig) { return this; } + /** + * As defined by {@link TerminationConfig#withUnimprovedSpentLimit(Duration)}, but returns this. + */ + public @NonNull SolverConfig withTerminationUnimprovedSpentLimit(@NonNull Duration unimprovedSpentLimit) { + if (terminationConfig == null) { + terminationConfig = new TerminationConfig(); + } + terminationConfig.setUnimprovedSpentLimit(unimprovedSpentLimit); + return this; + } + public @NonNull SolverConfig withNearbyDistanceMeterClass(@NonNull Class> distanceMeterClass) { this.nearbyDistanceMeterClass = distanceMeterClass; diff --git a/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java b/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java index 0a0851db69..6b14df39c2 100644 --- a/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java +++ b/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java @@ -2,17 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import java.io.IOException; import java.io.InputStreamReader; -import java.io.Reader; import java.io.StringReader; import java.io.StringWriter; import java.io.UncheckedIOException; -import java.io.Writer; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; @@ -55,7 +55,6 @@ import ai.timefold.solver.core.impl.testdata.domain.record.TestdataRecordSolution; import org.apache.commons.io.IOUtils; -import org.assertj.core.api.Assertions; import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -71,26 +70,29 @@ class SolverConfigTest { @ParameterizedTest @ValueSource(strings = { TEST_SOLVER_CONFIG_WITHOUT_NAMESPACE, TEST_SOLVER_CONFIG_WITH_NAMESPACE }) void xmlConfigRemainsSameAfterReadWrite(String solverConfigResource) throws IOException { - SolverConfig jaxbSolverConfig = readSolverConfig(solverConfigResource); + var jaxbSolverConfig = readSolverConfig(solverConfigResource); - Writer stringWriter = new StringWriter(); + var stringWriter = new StringWriter(); solverConfigIO.write(jaxbSolverConfig, stringWriter); - String jaxbString = stringWriter.toString(); + var jaxbString = stringWriter.toString(); - String originalXml = IOUtils.toString( - SolverConfigTest.class.getResourceAsStream(solverConfigResource), StandardCharsets.UTF_8); + var originalXml = + IOUtils.toString(SolverConfigTest.class.getResourceAsStream(solverConfigResource), StandardCharsets.UTF_8); // During writing the solver config, the solver element's namespace is removed. - String solverElementWithNamespace = SolverConfig.XML_ELEMENT_NAME + " xmlns=\"" + SolverConfig.XML_NAMESPACE + "\""; + var solverElementWithNamespace = """ + %s xmlns="%s" + """.formatted(SolverConfig.XML_ELEMENT_NAME, SolverConfig.XML_NAMESPACE) + .trim(); if (originalXml.contains(solverElementWithNamespace)) { originalXml = originalXml.replace(solverElementWithNamespace, SolverConfig.XML_ELEMENT_NAME); } - assertThat(jaxbString).isXmlEqualTo(originalXml); + assertThat(jaxbString).isEqualToIgnoringWhitespace(originalXml); } @Test void readXmlConfigWithNamespace() { - SolverConfig solverConfig = readSolverConfig(TEST_SOLVER_CONFIG_WITH_NAMESPACE); + var solverConfig = readSolverConfig(TEST_SOLVER_CONFIG_WITH_NAMESPACE); assertThat(solverConfig).isNotNull(); assertThat(solverConfig.getPhaseConfigList()) @@ -103,7 +105,7 @@ void readXmlConfigWithNamespace() { } private SolverConfig readSolverConfig(String solverConfigResource) { - try (Reader reader = new InputStreamReader(SolverConfigTest.class.getResourceAsStream(solverConfigResource))) { + try (var reader = new InputStreamReader(SolverConfigTest.class.getResourceAsStream(solverConfigResource))) { return solverConfigIO.read(reader); } catch (IOException ioException) { throw new UncheckedIOException(ioException); @@ -112,29 +114,32 @@ private SolverConfig readSolverConfig(String solverConfigResource) { @Test void whiteCharsInClassName() { - String solutionClassName = "ai.timefold.solver.core.impl.testdata.domain.TestdataSolution"; - String xmlFragment = String.format("%n" - + " %s %n" // Intentionally included white chars around the class name. - + " %n" - + "", solutionClassName); - SolverConfig solverConfig = solverConfigIO.read(new StringReader(xmlFragment)); + var solutionClassName = "ai.timefold.solver.core.impl.testdata.domain.TestdataSolution"; + // Intentionally included white chars around the class name. + var xmlFragment = """ + + %s\s + + """.formatted(solutionClassName); + var solverConfig = solverConfigIO.read(new StringReader(xmlFragment)); assertThat(solverConfig.getSolutionClass().getName()).isEqualTo(solutionClassName); } @Test void readAndValidateInvalidSolverConfig_failsIndicatingTheIssue() { - String solverConfigXml = "\n" - + " \n" - + " \n" - + " \n" - // Intentionally wrong: variableName should be an attribute of the - + " subValue\n" - + " \n" - + " \n" - + " \n" - + ""; - - StringReader stringReader = new StringReader(solverConfigXml); + // Intentionally wrong: variableName should be an attribute of the + var solverConfigXml = """ + + + + + subValue + + + + """; + + var stringReader = new StringReader(solverConfigXml); assertThatExceptionOfType(TimefoldXmlSerializationException.class) .isThrownBy(() -> solverConfigIO.read(stringReader)) .withRootCauseExactlyInstanceOf(SAXParseException.class) @@ -143,7 +148,7 @@ void readAndValidateInvalidSolverConfig_failsIndicatingTheIssue() { @Test void withEasyScoreCalculatorClass() { - SolverConfig solverConfig = new SolverConfig(); + var solverConfig = new SolverConfig(); assertThat(solverConfig.getScoreDirectorFactoryConfig()).isNull(); solverConfig.withEasyScoreCalculatorClass(DummyEasyScoreCalculator.class); assertThat(solverConfig.getScoreDirectorFactoryConfig().getEasyScoreCalculatorClass()) @@ -152,7 +157,7 @@ void withEasyScoreCalculatorClass() { @Test void withConstraintProviderClass() { - SolverConfig solverConfig = new SolverConfig(); + var solverConfig = new SolverConfig(); assertThat(solverConfig.getScoreDirectorFactoryConfig()).isNull(); solverConfig.withConstraintProviderClass(DummyConstraintProvider.class); assertThat(solverConfig.getScoreDirectorFactoryConfig().getConstraintProviderClass()) @@ -161,25 +166,36 @@ void withConstraintProviderClass() { @Test void withTerminationSpentLimit() { - SolverConfig solverConfig = new SolverConfig(); + var solverConfig = new SolverConfig(); + var duration = Duration.ofMinutes(2); assertThat(solverConfig.getTerminationConfig()).isNull(); - solverConfig.withTerminationSpentLimit(Duration.ofMinutes(2)); + solverConfig.withTerminationSpentLimit(duration); assertThat(solverConfig.getTerminationConfig().getSpentLimit()) - .isEqualTo(Duration.ofMinutes(2)); + .isEqualTo(duration); + } + + @Test + void withTerminationUnimprovedSpentLimit() { + var solverConfig = new SolverConfig(); + var duration = Duration.ofMinutes(2); + assertThat(solverConfig.getTerminationConfig()).isNull(); + solverConfig.withTerminationUnimprovedSpentLimit(duration); + assertThat(solverConfig.getTerminationConfig().getUnimprovedSpentLimit()) + .isEqualTo(duration); } @Test void inherit() { - SolverConfig originalSolverConfig = readSolverConfig(TEST_SOLVER_CONFIG_WITHOUT_NAMESPACE); - SolverConfig inheritedSolverConfig = + var originalSolverConfig = readSolverConfig(TEST_SOLVER_CONFIG_WITHOUT_NAMESPACE); + var inheritedSolverConfig = new SolverConfig().inherit(originalSolverConfig); assertThat(inheritedSolverConfig).usingRecursiveComparison().isEqualTo(originalSolverConfig); } @Test void visitReferencedClasses() { - SolverConfig solverConfig = readSolverConfig(TEST_SOLVER_CONFIG_WITHOUT_NAMESPACE); - Consumer> classVisitor = mock(Consumer.class); + var solverConfig = readSolverConfig(TEST_SOLVER_CONFIG_WITHOUT_NAMESPACE); + var classVisitor = (Consumer>) mock(Consumer.class); solverConfig.visitReferencedClasses(classVisitor); verify(classVisitor, atLeastOnce()).accept(TestdataAnnotatedExtendedSolution.class); verify(classVisitor, atLeastOnce()).accept(TestdataEntity.class); @@ -201,7 +217,7 @@ void solutionIsARecord() { var solverConfig = new SolverConfig() .withSolutionClass(DummyRecordSolution.class) .withEntityClasses(TestdataEntity.class); - Assertions.assertThatThrownBy(() -> SolverFactory.create(solverConfig)) + assertThatThrownBy(() -> SolverFactory.create(solverConfig)) .hasMessageContaining(DummyRecordSolution.class.getSimpleName()) .hasMessageContaining("record"); } @@ -211,7 +227,7 @@ void entityIsARecord() { var solverConfig = new SolverConfig() .withSolutionClass(DummySolutionWithRecordEntity.class) .withEntityClasses(DummyRecordEntity.class); - Assertions.assertThatThrownBy(() -> SolverFactory.create(solverConfig)) + assertThatThrownBy(() -> SolverFactory.create(solverConfig)) .hasMessageContaining(DummyRecordEntity.class.getSimpleName()) .hasMessageContaining("record"); } @@ -227,7 +243,7 @@ void variableWithPlanningIdIsARecord() { .buildSolver(); var solution = TestdataRecordSolution.generateSolution(); - Assertions.assertThatNoException().isThrownBy(() -> solver.solve(solution)); + assertThatNoException().isThrownBy(() -> solver.solve(solution)); } @Test @@ -241,7 +257,7 @@ void domainClassesAreInterfaces() { .buildSolver(); var solution = TestdataInterfaceSolution.generateSolution(); - Assertions.assertThatNoException().isThrownBy(() -> solver.solve(solution)); + assertThatNoException().isThrownBy(() -> solver.solve(solution)); } @Test @@ -250,7 +266,7 @@ void entityWithTwoPlanningListVariables() { .withSolutionClass(DummySolutionWithTwoListVariablesEntity.class) .withEntityClasses(DummyEntityWithTwoListVariables.class) .withEasyScoreCalculatorClass(DummyRecordEasyScoreCalculator.class); - Assertions.assertThatThrownBy(() -> SolverFactory.create(solverConfig)) + assertThatThrownBy(() -> SolverFactory.create(solverConfig)) .isExactlyInstanceOf(UnsupportedOperationException.class) .hasMessageContaining(DummyEntityWithTwoListVariables.class.getSimpleName()) .hasMessageContaining("firstListVariable") @@ -263,7 +279,7 @@ void entityWithMixedBasicAndPlanningListVariables() { .withSolutionClass(DummySolutionWithMixedSimpleAndListVariableEntity.class) .withEntityClasses(DummyEntityWithMixedSimpleAndListVariable.class) .withEasyScoreCalculatorClass(DummyRecordEasyScoreCalculator.class); - Assertions.assertThatThrownBy(() -> SolverFactory.create(solverConfig)) + assertThatThrownBy(() -> SolverFactory.create(solverConfig)) .isExactlyInstanceOf(UnsupportedOperationException.class) .hasMessageContaining(DummyEntityWithMixedSimpleAndListVariable.class.getSimpleName()) .hasMessageContaining("listVariable") @@ -278,7 +294,7 @@ private record DummyRecordSolution( } @PlanningSolution - private class DummySolutionWithRecordEntity { + private static class DummySolutionWithRecordEntity { @PlanningEntityCollectionProperty List entities; @@ -294,7 +310,7 @@ private record DummyRecordEntity( } @PlanningSolution - private class DummySolutionWithMixedSimpleAndListVariableEntity { + private static class DummySolutionWithMixedSimpleAndListVariableEntity { @PlanningEntityCollectionProperty List entities; @@ -311,7 +327,7 @@ private class DummySolutionWithMixedSimpleAndListVariableEntity { } @PlanningEntity - private class DummyEntityWithMixedSimpleAndListVariable { + private static class DummyEntityWithMixedSimpleAndListVariable { @PlanningListVariable(valueRangeProviderRefs = "listValueRange") private List listVariable; @@ -322,7 +338,7 @@ private class DummyEntityWithMixedSimpleAndListVariable { } @PlanningSolution - private class DummySolutionWithTwoListVariablesEntity { + private static class DummySolutionWithTwoListVariablesEntity { @PlanningEntityCollectionProperty List entities; @@ -339,7 +355,7 @@ private class DummySolutionWithTwoListVariablesEntity { } @PlanningEntity - private class DummyEntityWithTwoListVariables { + private static class DummyEntityWithTwoListVariables { @PlanningListVariable(valueRangeProviderRefs = "firstListValueRange") private List firstListVariable; @@ -350,7 +366,7 @@ private class DummyEntityWithTwoListVariables { } @PlanningEntity - private class DummyEntityForListVariable { + private static class DummyEntityForListVariable { } @@ -394,7 +410,7 @@ public abstract static class DummyMoveIteratorFactory implements MoveIteratorFac public abstract static class DummyMoveListFactory implements MoveListFactory { } - public class DummyNearbyDistanceClass implements NearbyDistanceMeter { + public static class DummyNearbyDistanceClass implements NearbyDistanceMeter { @Override public double getNearbyDistance(String origin, String destination) {