diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java index d0263ddb3e..6b503a17df 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java @@ -1,5 +1,6 @@ package ai.timefold.solver.service.definition.api.domain; +import java.util.Map; import java.util.Set; import jakarta.validation.constraints.Positive; @@ -11,7 +12,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; -@Schema(additionalProperties = Schema.False.class) public record RunConfiguration( @Schema(nullable = true, description = "Optional name to be given to the dataset. If not provided, the name will be generated.") @Size( @@ -21,13 +21,11 @@ public record RunConfiguration( description = "Optional maximum number of threads to be used for solving.", minimum = "1") @JsonInclude(JsonInclude.Include.NON_EMPTY) @Positive Integer maxThreadCount, @JsonInclude(JsonInclude.Include.NON_NULL) @Schema( - description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set tags) { + description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set tags, + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(hidden = true) Map options) { public RunConfiguration(String name, SolverTerminationConfig termination, Integer maxThreadCount, Set tags) { - this.name = name; - this.termination = termination; - this.tags = tags; - this.maxThreadCount = maxThreadCount; + this(name, termination, maxThreadCount, tags, null); } public RunConfiguration(String name, SolverTerminationConfig termination) { @@ -49,7 +47,7 @@ public RunConfiguration(String name) { * @return a copy of this instance with given termination, never null */ public RunConfiguration withTermination(SolverTerminationConfig termination) { - return new RunConfiguration(name(), termination, maxThreadCount(), tags()); + return new RunConfiguration(name(), termination, maxThreadCount(), tags(), options()); } public RunConfiguration override(RunConfiguration configuration) { @@ -57,6 +55,7 @@ public RunConfiguration override(RunConfiguration configuration) { SolverTerminationConfig finalTermination = termination; Integer finalMaxThreadCount = maxThreadCount; Set finalTags = tags; + Map finalOptions = options; if (configuration == null) { return this; @@ -70,16 +69,20 @@ public RunConfiguration override(RunConfiguration configuration) { finalMaxThreadCount = configuration.maxThreadCount(); } + if (finalOptions == null) { + finalOptions = configuration.options(); + } + if (finalTermination == null) { finalTermination = configuration.termination(); } else { finalTermination = finalTermination.override(configuration.termination()); } - if ((finalTags == null || !finalTags.isEmpty()) && configuration.tags() != null && !configuration.tags().isEmpty()) { - finalTags = configuration.tags; + if ((finalTags == null || finalTags.isEmpty()) && configuration.tags() != null && !configuration.tags().isEmpty()) { + finalTags = configuration.tags(); } - return new RunConfiguration(finalName, finalTermination, finalMaxThreadCount, finalTags); + return new RunConfiguration(finalName, finalTermination, finalMaxThreadCount, finalTags, finalOptions); } } diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/api/domain/RunConfigurationTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/domain/RunConfigurationTest.java new file mode 100644 index 0000000000..dcbbc1023a --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/domain/RunConfigurationTest.java @@ -0,0 +1,150 @@ +package ai.timefold.solver.service.definition.api.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import ai.timefold.solver.service.definition.api.termination.SolverTerminationConfig; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +class RunConfigurationTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void convenienceConstructorsLeaveOptionsNull() { + assertThat(new RunConfiguration("dataset", null, 4, Set.of("a")).options()).isNull(); + assertThat(new RunConfiguration("dataset", null).options()).isNull(); + assertThat(new RunConfiguration(4, null).options()).isNull(); + assertThat(new RunConfiguration("dataset").options()).isNull(); + } + + @Test + void overrideFillsMissingOptionsFromFallback() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast")); + + RunConfiguration merged = primary.override(fallback); + + assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "fast")); + } + + @Test + void overrideKeepsPrimaryOptionsWhenPresent() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "accurate")); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast")); + + RunConfiguration merged = primary.override(fallback); + + // Options are replaced wholesale, never merged key-by-key. + assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "accurate")); + } + + @Test + void overrideKeepsPrimaryOptionsWhenPresentWithDisjointFallbackKeys() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "accurate")); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("logLevel", "debug")); + + RunConfiguration merged = primary.override(fallback); + + assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "accurate")); + } + + @Test + void overrideKeepsPrimaryEmptyOptionsInsteadOfInheriting() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of()); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast")); + + RunConfiguration merged = primary.override(fallback); + + // Only a null options map inherits from the fallback; an empty one is a deliberate "no options". + assertThat(merged.options()).isEmpty(); + } + + @Test + void overrideWithNullConfigurationKeepsOptions() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "fast")); + + assertThat(primary.override(null).options()).containsExactlyEntriesOf(Map.of("solver", "fast")); + } + + @Test + void withTerminationPreservesOptions() { + RunConfiguration configuration = + new RunConfiguration("dataset", null, 4, Set.of("nightly"), Map.of("solver", "fast")); + + RunConfiguration copy = configuration.withTermination(new SolverTerminationConfig(Duration.ofMinutes(1), null)); + + assertThat(copy.options()).containsExactlyEntriesOf(Map.of("solver", "fast")); + assertThat(copy.termination().spentLimit()).isEqualTo(Duration.ofMinutes(1)); + assertThat(copy.name()).isEqualTo("dataset"); + assertThat(copy.maxThreadCount()).isEqualTo(4); + assertThat(copy.tags()).containsExactly("nightly"); + } + + @Test + void deserializesOptionsFromJson() throws JsonProcessingException { + String json = """ + { + "name": "dataset", + "options": { + "solver": "fast", + "logLevel": "debug" + } + } + """; + + RunConfiguration configuration = mapper.readValue(json, RunConfiguration.class); + + assertThat(configuration.name()).isEqualTo("dataset"); + assertThat(configuration.options()) + .containsExactlyInAnyOrderEntriesOf(Map.of("solver", "fast", "logLevel", "debug")); + } + + @Test + void omitsNullOptionsFromJson() throws JsonProcessingException { + String json = mapper.writeValueAsString(new RunConfiguration("dataset")); + + assertThat(json).doesNotContain("options"); + } + + @Test + void serializesOptionsWhenPresent() throws JsonProcessingException { + RunConfiguration configuration = + new RunConfiguration(null, null, null, null, Map.of("solver", "fast")); + + String json = mapper.writeValueAsString(configuration); + + assertThat(json).contains("\"options\":{\"solver\":\"fast\"}"); + } + + @Test + void overrideFillsNullTagsFromFallback() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, null, null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null); + + assertThat(primary.override(fallback).tags()).containsExactly("nightly"); + } + + @Test + void overrideFillsEmptyTagsFromFallback() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null); + + assertThat(primary.override(fallback).tags()).containsExactly("nightly"); + } + + @Test + void overrideKeepsPrimaryTagsWhenPresent() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of("adhoc"), null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null); + + assertThat(primary.override(fallback).tags()).containsExactly("adhoc"); + } +} diff --git a/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java b/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java index d4c4dcb90b..6bf169861c 100644 --- a/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java +++ b/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java @@ -12,6 +12,7 @@ import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -30,9 +31,11 @@ import ai.timefold.solver.core.api.score.HardMediumSoftScore; import ai.timefold.solver.service.definition.api.SolverModel; import ai.timefold.solver.service.definition.api.SolvingStatus; +import ai.timefold.solver.service.definition.api.domain.Configuration; import ai.timefold.solver.service.definition.api.domain.Metadata; import ai.timefold.solver.service.definition.api.domain.ModelRequest; import ai.timefold.solver.service.definition.api.domain.ModelResponse; +import ai.timefold.solver.service.definition.api.domain.RunConfiguration; import ai.timefold.solver.service.definition.api.rest.OperationOnPost; import ai.timefold.solver.service.definition.api.validation.IssueCode; import ai.timefold.solver.service.definition.api.validation.IssueSeverity; @@ -62,6 +65,10 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; @@ -88,6 +95,9 @@ public class EmployeeScheduleResourceTest { @Connector("smallrye-in-memory") InMemoryConnector connector; + @Inject + ObjectMapper objectMapper; + InMemorySink initSolutionSink; InMemorySink bestSolutionSink; InMemorySink finalBestSolutionSink; @@ -332,6 +342,55 @@ void getIssueTypeByCode() { }); } + @Test + void postAcceptsCustomRunOptions() { + RunConfiguration runConfiguration = + new RunConfiguration(null, null, null, Set.of("options-e2e"), Map.of("customOption", "customValue")); + ModelRequest modelRequest = + new ModelRequest(createInputEmployeeSchedule()) + .withConfiguration(Configuration. empty().withRun(runConfiguration)); + + // Options are hidden from the OpenAPI schema, so the generated JSON schema must not forbid them. + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(modelRequest) + .when() + .post("/schedules?operation=" + OperationOnPost.NONE.name()) + .then() + .log().ifError() + .statusCode(202); + + // Await the dataset so the asynchronous computation cannot leak into the next test. + await() + .atMost(TEST_AWAIT_TIMEOUT_DURATION) + .pollInterval(TEST_POLL_INTERVAL_MILLIS) + .until(() -> !datasetComputedSink.received().isEmpty()); + } + + @Test + void postRejectsUnknownRunConfigurationProperty() throws Exception { + ModelRequest modelRequest = + new ModelRequest(createInputEmployeeSchedule()) + .withConfiguration(Configuration. empty() + .withRun(new RunConfiguration("unknown-property-e2e"))); + + ObjectNode body = objectMapper.valueToTree(modelRequest); + JsonNode runNode = body.path(ModelRequest.ModelRequestAttribute.CONFIG.value()).path("run"); + assertThat(runNode.isObject()).as("run configuration should be serialized as an object").isTrue(); + ((ObjectNode) runNode).put("notARealOption", "boom"); + + // Permitting unknown properties in the schema must not weaken the strict Jackson mapping. + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(objectMapper.writeValueAsString(body)) + .when() + .post("/schedules?operation=" + OperationOnPost.NONE.name()) + .then() + .statusCode(400); + } + private static EmployeeSchedule awaitFeasiblyAssigned(Metadata metadata) { await() .atMost(TEST_AWAIT_TIMEOUT_DURATION)