diff --git a/docs/src/antora.yml b/docs/src/antora.yml index 3f2a450dff9..e1e99d89f37 100644 --- a/docs/src/antora.yml +++ b/docs/src/antora.yml @@ -14,6 +14,9 @@ asciidoc: spring-boot-version: ${version.org.springframework.boot} logback-version: ${version.ch.qos.logback} exec-maven-plugin-version: ${version.exec.plugin} + assembly-plugin-version: ${version.assembly.plugin} + compiler-plugin-version: ${version.compiler.plugin} + junit-version: ${version.org.junit.jupiter} rewrite-maven-plugin-version: ${version.rewrite.plugin} nav: - modules/ROOT/nav.adoc diff --git a/docs/src/modules/ROOT/pages/_attributes.adoc b/docs/src/modules/ROOT/pages/_attributes.adoc index f5ec1b01a2a..f823896d5c7 100644 --- a/docs/src/modules/ROOT/pages/_attributes.adoc +++ b/docs/src/modules/ROOT/pages/_attributes.adoc @@ -3,6 +3,7 @@ :quickstarts-archive-url: https://github.com/TimefoldAI/timefold-quickstarts/archive/refs/heads/stable.tar.gz :hello-world-java-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/hello-world :spring-boot-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/spring-boot-integration -:quarkus-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/school-timetabling -:vrp-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/vehicle-routing +:quarkus-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/quarkus-integration +:quarkus-kotlin-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/quarkus-integration-kotlin +:vrp-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/use-cases/vehicle-routing :service-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/service diff --git a/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc index 44d5d92a838..528bb75535c 100644 --- a/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc @@ -97,6 +97,7 @@ Create a Maven or Gradle build file and add these dependencies: * `timefold-solver-core` (compile scope) to solve the school timetable problem. * A xref:running-timefold-solver/solver-diagnostics.adoc#logging[logging] implementation, such as `logback-classic` (runtime scope), to see what Timefold Solver is doing. +* `junit-jupiter` (test scope) to test the constraints. [tabs] ==== @@ -135,6 +136,11 @@ Your `pom.xml` file has the following content: logback-classic {logback-version} + + org.junit.jupiter + junit-jupiter + {junit-version} + @@ -148,6 +154,11 @@ Your `pom.xml` file has the following content: logback-classic runtime + + org.junit.jupiter + junit-jupiter + test + @@ -180,6 +191,7 @@ plugins { def timefoldSolverVersion = "{timefold-solver-version}" def logbackVersion = "{logback-version}" +def junitVersion = "{junit-version}" group = "org.acme" version = "1.0-SNAPSHOT" @@ -193,6 +205,10 @@ dependencies { implementation "ai.timefold.solver:timefold-solver-core" runtimeOnly "ch.qos.logback:logback-classic:${logbackVersion}" + + testImplementation platform("org.junit:junit-bom:${junitVersion}") + testImplementation "org.junit.jupiter:junit-jupiter" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } java { @@ -214,6 +230,7 @@ application { } test { + useJUnitPlatform() // Log the test execution results. testLogging { events "passed", "skipped", "failed" @@ -421,8 +438,6 @@ import org.slf4j.LoggerFactory import java.time.DayOfWeek import java.time.Duration import java.time.LocalTime -import java.util.Objects -import java.util.function.Function import java.util.stream.Collectors @@ -443,7 +458,7 @@ object TimetableApp { ) // Load the problem - val problem = generateDemoData(DemoData.SMALL) + val problem = generateDemoData() // Solve the problem val solver = solverFactory.buildSolver() @@ -503,26 +518,16 @@ object TimetableApp { LOGGER.info("") val rooms = timeTable.rooms val lessons = timeTable.lessons - val lessonMap = lessons.stream() + val lessonMap: Map>> = lessons .filter { lesson: Lesson -> lesson.timeslot != null && lesson.room != null } - .collect( - Collectors.groupingBy>>( - Function { obj: Lesson -> obj.timeslot }, Collectors.groupingBy( - Function { obj: Lesson -> obj.room }) - ) - ) + .groupBy { lesson: Lesson -> lesson.timeslot!! } + .mapValues { (_, timeslotLessons) -> timeslotLessons.groupBy { lesson: Lesson -> lesson.room!! } } LOGGER.info("| | " + rooms.stream() .map { room: Room -> String.format("%-10s", room.name) }.collect(Collectors.joining(" | ")) + " |" ) LOGGER.info("|" + "------------|".repeat(rooms.size + 1)) for (timeslot in timeTable.timeslots) { - val cells = rooms.stream() - .map { room: Room -> - val byRoomMap = lessonMap[timeslot] - ?: return@map emptyList() - val cellLessons = byRoomMap[room]!! - Objects.requireNonNullElse(cellLessons, emptyList()) - }.toList() + val cells = rooms.map { room: Room -> lessonMap[timeslot]?.get(room) ?: emptyList() } LOGGER.info("| " + String.format( "%-10s", @@ -641,7 +646,7 @@ Kotlin:: [source,kotlin] ---- // Load the problem -val problem = generateDemoData(DemoData.SMALL) +val problem = generateDemoData() // Solve the problem val solver = solverFactory.buildSolver() @@ -751,17 +756,17 @@ import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier; class TimetableConstraintProviderTest { private static final Room ROOM1 = new Room("Room1"); - private static final Timeslot TIMESLOT1 = new Timeslot(DayOfWeek.MONDAY, LocalTime.NOON); - private static final Timeslot TIMESLOT2 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON); + private static final Timeslot TIMESLOT1 = new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.NOON); + private static final Timeslot TIMESLOT2 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.NOON); ConstraintVerifier constraintVerifier = ConstraintVerifier.build( new TimetableConstraintProvider(), Timetable.class, Lesson.class); @Test void roomConflict() { - Lesson firstLesson = new Lesson(1, "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1); - Lesson conflictingLesson = new Lesson(2, "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1); - Lesson nonConflictingLesson = new Lesson(3, "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1); + Lesson firstLesson = new Lesson("1", "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1); + Lesson conflictingLesson = new Lesson("2", "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1); + Lesson nonConflictingLesson = new Lesson("3", "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1); constraintVerifier.verifyThat(TimetableConstraintProvider::roomConflict) .given(firstLesson, conflictingLesson, nonConflictingLesson) .penalizesBy(1); @@ -781,8 +786,7 @@ Create the `src/test/kotlin/org/acme/schooltimetabling/solver/TimetableConstrain ---- package org.acme.schooltimetabling.solver -import ai.timefold.solver.core.api.score.stream.ConstraintFactory -import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier; +import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier import org.acme.schooltimetabling.domain.Lesson import org.acme.schooltimetabling.domain.Room import org.acme.schooltimetabling.domain.Timeslot @@ -798,22 +802,18 @@ internal class TimetableConstraintProviderTest { @Test fun roomConflict() { - val firstLesson = Lesson(1, "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1) - val conflictingLesson = Lesson(2, "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1) - val nonConflictingLesson = Lesson(3, "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1) - constraintVerifier.verifyThat { obj: TimetableConstraintProvider, constraintFactory: ConstraintFactory? -> - obj.roomConflict( - constraintFactory - ) - } + val firstLesson = Lesson("1", "Subject1", "Teacher1", "Group1").apply { timeslot = TIMESLOT1; room = ROOM1 } + val conflictingLesson = Lesson("2", "Subject2", "Teacher2", "Group2").apply { timeslot = TIMESLOT1; room = ROOM1 } + val nonConflictingLesson = Lesson("3", "Subject3", "Teacher3", "Group3").apply { timeslot = TIMESLOT2; room = ROOM1 } + constraintVerifier.verifyThat(TimetableConstraintProvider::roomConflict) .given(firstLesson, conflictingLesson, nonConflictingLesson) .penalizesBy(1) } companion object { private val ROOM1 = Room("Room1") - private val TIMESLOT1 = Timeslot(DayOfWeek.MONDAY, LocalTime.NOON) - private val TIMESLOT2 = Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON) + private val TIMESLOT1 = Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.NOON) + private val TIMESLOT2 = Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.NOON) } } ---- @@ -882,7 +882,7 @@ Maven:: -- In Maven, add the following to your `pom.xml`: -[source,xml,options="nowrap"] +[source,xml,options="nowrap",subs=attributes+] ---- ... @@ -890,7 +890,7 @@ In Maven, add the following to your `pom.xml`: org.apache.maven.plugins maven-assembly-plugin - ${version.assembly.plugin} + {assembly-plugin-version} hello-world-run false diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc index d2059da4ee5..9f16535fa63 100644 --- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc @@ -96,11 +96,12 @@ Java:: 1.0-SNAPSHOT - 11 + {java-version} UTF-8 {quarkus-version} {timefold-solver-version} + {compiler-plugin-version} @@ -189,11 +190,13 @@ Kotlin:: 1.0-SNAPSHOT - 11 + {java-version} UTF-8 {quarkus-version} {timefold-solver-version} + {compiler-plugin-version} + 2.4.10 @@ -236,7 +239,6 @@ Kotlin:: org.jetbrains.kotlin kotlin-stdlib - 1.9.22 @@ -355,14 +357,14 @@ import org.acme.vehiclerouting.domain.VehicleRoutePlan; @Path("route-plans") public class VehicleRoutePlanResource { - private final SolverManager solverManager; + private final SolverManager solverManager; public VehicleRoutePlanResource() { this.solverManager = null; } @Inject - public VehicleRoutePlanResource(SolverManager solverManager) { + public VehicleRoutePlanResource(SolverManager solverManager) { this.solverManager = solverManager; } @@ -370,8 +372,10 @@ public class VehicleRoutePlanResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces(MediaType.APPLICATION_JSON) public VehicleRoutePlan solve(VehicleRoutePlan problem) { + // Jackson skips the constructor that calculates the driving times + problem.initDrivingTimeMaps(); String jobId = UUID.randomUUID().toString(); - SolverJob solverJob = solverManager.solveBuilder() + SolverJob solverJob = solverManager.solveBuilder() .withProblemId(jobId) .withProblem(problem) .run(); @@ -412,14 +416,14 @@ import org.acme.vehiclerouting.domain.VehicleRoutePlan @Path("route-plans") class VehicleRoutePlanResource { - private val solverManager: SolverManager? + private val solverManager: SolverManager? constructor() { this.solverManager = null } @Inject - constructor(solverManager: SolverManager?) { + constructor(solverManager: SolverManager?) { this.solverManager = solverManager } @@ -427,6 +431,8 @@ class VehicleRoutePlanResource { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) fun solve(problem: VehicleRoutePlan): VehicleRoutePlan { + // Jackson skips the constructor that calculates the driving times + problem.initDrivingTimeMaps() val jobId = UUID.randomUUID().toString() val solverJob = solverManager!!.solveBuilder() .withProblemId(jobId) @@ -619,7 +625,7 @@ class VehicleRoutingConstraintProviderTest { Kotlin:: + -- -Create the `src/test/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintProviderTest.kt` class: +Create the `src/test/kotlin/org/acme/vehiclerouting/solver/VehicleRoutingConstraintProviderTest.kt` class: [source,kotlin] ---- @@ -633,7 +639,6 @@ import java.util.Arrays import jakarta.inject.Inject -import ai.timefold.solver.core.api.score.stream.ConstraintFactory import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier import org.acme.vehiclerouting.domain.Location @@ -659,15 +664,11 @@ internal class VehicleRoutingConstraintProviderTest { val tomorrow_10_00 = LocalDateTime.of(TOMORROW, LocalTime.of(10, 0)) val vehicleA = Vehicle("1", 100, LOCATION_1, tomorrow_07_00) val visit1 = Visit("2", "John", LOCATION_2, 80, tomorrow_08_00, tomorrow_10_00, Duration.ofMinutes(30L)) - vehicleA.visits!!.add(visit1) + vehicleA.visits.add(visit1) val visit2 = Visit("3", "Paul", LOCATION_3, 40, tomorrow_08_00, tomorrow_10_00, Duration.ofMinutes(30L)) - vehicleA.visits!!.add(visit2) + vehicleA.visits.add(visit2) - constraintVerifier!!.verifyThat { obj: VehicleRoutingConstraintProvider, factory: ConstraintFactory? -> - obj.vehicleCapacity( - factory!! - ) - } + constraintVerifier.verifyThat(VehicleRoutingConstraintProvider::vehicleCapacity) .given(vehicleA, visit1, visit2) .penalizesBy(20) } @@ -709,9 +710,9 @@ This way, constraint weight tweaking does not break the unit tests. ==== Test the solver -In a JUnit test, generate a test dataset and send it to the `VehicleRoutePlanResource` to solve. +In a JUnit test, send a test dataset to the `VehicleRoutePlanResource` to solve. -Add some dependencies in your `pom.xml`: +Add the subsequent dependency in your `pom.xml`: [source,xml] ---- @@ -719,13 +720,10 @@ Add some dependencies in your `pom.xml`: rest-assured test - - org.awaitility - awaitility - test - ---- +Save the JSON request body of the earlier `curl` command as the `src/test/resources/testing-route-plan.json` file. + Then create the test itself: [tabs] @@ -739,61 +737,36 @@ Create the `src/test/java/org/acme/vehiclerouting/rest/VehicleRoutePlanResourceT ---- package org.acme.vehiclerouting.rest; -import static io.restassured.RestAssured.get; import static io.restassured.RestAssured.given; -import static org.awaitility.Awaitility.await; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.time.Duration; +import java.io.File; -import ai.timefold.solver.core.api.solver.SolverStatus; +import ai.timefold.solver.core.api.score.HardSoftScore; -import org.acme.vehiclerouting.domain.VehicleRoutePlan; import org.junit.jupiter.api.Test; import io.quarkus.test.junit.QuarkusTest; import io.restassured.http.ContentType; @QuarkusTest -public class VehicleRoutePlanResourceTest { +class VehicleRoutePlanResourceTest { @Test - public void solveDemoDataUntilFeasible() { - VehicleRoutePlan vehicleRoutePlan = given() - .when().get("/demo-data/FIRENZE") - .then() - .statusCode(200) - .extract() - .as(VehicleRoutePlan.class); - - String jobId = given() + void solveUntilFeasible() { + String score = given() .contentType(ContentType.JSON) - .body(vehicleRoutePlan) - .expect().contentType(ContentType.TEXT) + .body(new File("src/test/resources/testing-route-plan.json")) + .expect().contentType(ContentType.JSON) .when().post("/route-plans") .then() .statusCode(200) .extract() - .asString(); - - await() - .atMost(Duration.ofMinutes(1)) - .pollInterval(Duration.ofMillis(500L)) - .until(() -> SolverStatus.NOT_SOLVING.name().equals( - get("/route-plans/" + jobId + "/status") - .jsonPath().get("solverStatus"))); - - VehicleRoutePlan solution = get("/route-plans/" + jobId).then().extract().as(VehicleRoutePlan.class); - assertEquals(solution.getSolverStatus(), SolverStatus.NOT_SOLVING); - assertNotNull(solution.getVehicles()); - assertNotNull(solution.getVisits()); - assertNotNull(solution.getVehicles().get(0).getVisits()); - assertTrue(solution.getScore().isFeasible()); + .path("score"); + + assertTrue(HardSoftScore.parseScore(score).isFeasible()); } } - ---- -- Kotlin:: @@ -805,60 +778,32 @@ Create the `src/test/kotlin/org/acme/vehiclerouting/rest/VehicleRoutePlanResourc ---- package org.acme.vehiclerouting.rest -import java.time.Duration +import java.io.File -import ai.timefold.solver.core.api.solver.SolverStatus +import ai.timefold.solver.core.api.score.HardSoftScore -import org.acme.vehiclerouting.domain.VehicleRoutePlan +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import io.quarkus.test.junit.QuarkusTest -import io.restassured.RestAssured +import io.restassured.RestAssured.given import io.restassured.http.ContentType -import org.awaitility.Awaitility - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue - @QuarkusTest class VehicleRoutePlanResourceTest { @Test - fun solveDemoDataUntilFeasible() { - val vehicleRoutePlan = RestAssured.given() - .`when`()["/demo-data/FIRENZE"] - .then() - .statusCode(200) - .extract() - .`as`(VehicleRoutePlan::class.java) - - val jobId = RestAssured.given() + fun solveUntilFeasible() { + val score: String = given() .contentType(ContentType.JSON) - .body(vehicleRoutePlan) - .expect().contentType(ContentType.TEXT) + .body(File("src/test/resources/testing-route-plan.json")) + .expect().contentType(ContentType.JSON) .`when`().post("/route-plans") .then() .statusCode(200) .extract() - .asString() - - Awaitility.await() - .atMost(Duration.ofMinutes(1)) - .pollInterval(Duration.ofMillis(500L)) - .until { - SolverStatus.NOT_SOLVING.name == RestAssured.get("/route-plans/$jobId/status") - .jsonPath().get("solverStatus") - } - - val solution = RestAssured.get("/route-plans/$jobId").then().extract().`as`( - VehicleRoutePlan::class.java - ) - assertEquals(solution.solverStatus, SolverStatus.NOT_SOLVING) - assertNotNull(solution.vehicles) - assertNotNull(solution.visits) - assertNotNull(solution.vehicles!!.get(0).visits) - assertTrue(solution.score!!.isFeasible()) + .path("score") + + assertTrue(HardSoftScore.parseScore(score).isFeasible) } } ---- diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc index 9549061b6e7..b8a8367361d 100644 --- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc @@ -60,7 +60,7 @@ public class VehicleRoutingConstraintProvider implements ConstraintProvider { protected Constraint vehicleCapacity(ConstraintFactory factory) { return factory.forEach(Vehicle.class) .filter(vehicle -> vehicle.getTotalDemand() > vehicle.getCapacity()) - .penalizeLong(HardSoftScore.ONE_HARD, + .penalize(HardSoftScore.ONE_HARD, vehicle -> vehicle.getTotalDemand() - vehicle.getCapacity()) .asConstraint(VEHICLE_CAPACITY); } @@ -68,14 +68,14 @@ public class VehicleRoutingConstraintProvider implements ConstraintProvider { protected Constraint serviceFinishedAfterMaxEndTime(ConstraintFactory factory) { return factory.forEach(Visit.class) .filter(Visit::isServiceFinishedAfterMaxEndTime) - .penalizeLong(HardSoftScore.ONE_HARD, + .penalize(HardSoftScore.ONE_HARD, Visit::getServiceFinishedDelayInMinutes) .asConstraint(SERVICE_FINISHED_AFTER_MAX_END_TIME); } protected Constraint minimizeTravelTime(ConstraintFactory factory) { return factory.forEach(Vehicle.class) - .penalizeLong(HardSoftScore.ONE_SOFT, + .penalize(HardSoftScore.ONE_SOFT, Vehicle::getTotalDrivingTimeSeconds) .asConstraint(MINIMIZE_TRAVEL_TIME); } @@ -110,26 +110,26 @@ class VehicleRoutingConstraintProvider : ConstraintProvider { ) } - protected fun vehicleCapacity(factory: ConstraintFactory): Constraint { + fun vehicleCapacity(factory: ConstraintFactory): Constraint { return factory.forEach(Vehicle::class.java) .filter({ vehicle: Vehicle -> vehicle.totalDemand > vehicle.capacity }) - .penalizeLong( + .penalize( HardSoftScore.ONE_HARD ) { vehicle: Vehicle -> vehicle.totalDemand - vehicle.capacity } .asConstraint(VEHICLE_CAPACITY) } - protected fun serviceFinishedAfterMaxEndTime(factory: ConstraintFactory): Constraint { + fun serviceFinishedAfterMaxEndTime(factory: ConstraintFactory): Constraint { return factory.forEach(Visit::class.java) .filter({ obj: Visit -> obj.isServiceFinishedAfterMaxEndTime }) - .penalizeLong(HardSoftScore.ONE_HARD, + .penalize(HardSoftScore.ONE_HARD, { obj: Visit -> obj.serviceFinishedDelayInMinutes }) .asConstraint(SERVICE_FINISHED_AFTER_MAX_END_TIME) } - protected fun minimizeTravelTime(factory: ConstraintFactory): Constraint { + fun minimizeTravelTime(factory: ConstraintFactory): Constraint { return factory.forEach(Vehicle::class.java) - .penalizeLong(HardSoftScore.ONE_SOFT, + .penalize(HardSoftScore.ONE_SOFT, { obj: Vehicle -> obj.totalDrivingTimeSeconds }) .asConstraint(MINIMIZE_TRAVEL_TIME) } diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc index 8f305fa201d..e75d87694ca 100644 --- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc @@ -12,6 +12,7 @@ image::quickstart/vehicle-routing/vehicleRoutingClassDiagramPure.png[] The `Location` class is used to represent the destination for deliveries or the home location for vehicles. The `drivingTimeSeconds` map contains the time required to drive from `this` location to any other location. This field will be initialized later. +In JSON, a location is a `[latitude, longitude]` array, and the `drivingTimeSeconds` map is left out. [tabs] ==== @@ -26,14 +27,24 @@ package org.acme.vehiclerouting.domain; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +@JsonFormat(shape = JsonFormat.Shape.ARRAY) +@JsonPropertyOrder({ "latitude", "longitude" }) public class Location { private double latitude; private double longitude; + @JsonIgnore private Map drivingTimeSeconds; - public Location(double latitude, double longitude) { + @JsonCreator + public Location(@JsonProperty("latitude") double latitude, @JsonProperty("longitude") double longitude) { this.latitude = latitude; this.longitude = longitude; } @@ -70,7 +81,19 @@ Create the `src/main/kotlin/org/acme/vehiclerouting/domain/Location.kt` class: ---- package org.acme.vehiclerouting.domain -class Location @JsonCreator constructor(val latitude: Double, val longitude: Double) { +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonPropertyOrder + +@JsonFormat(shape = JsonFormat.Shape.ARRAY) +@JsonPropertyOrder("latitude", "longitude") +class Location @JsonCreator constructor( + @JsonProperty("latitude") val latitude: Double, + @JsonProperty("longitude") val longitude: Double) { + + @JsonIgnore var drivingTimeSeconds: Map? = null fun getDrivingTimeTo(location: Location): Long { @@ -124,6 +147,11 @@ import ai.timefold.solver.core.api.domain.entity.PlanningEntity; import ai.timefold.solver.core.api.domain.common.PlanningId; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.JsonIdentityReference; +import com.fasterxml.jackson.annotation.ObjectIdGenerators; + +@JsonIdentityInfo(scope = Vehicle.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") @PlanningEntity public class Vehicle { @@ -134,8 +162,9 @@ public class Vehicle { private LocalDateTime departureTime; + @JsonIdentityReference(alwaysAsId = true) @PlanningListVariable - private List visits; + private List visits = new ArrayList<>(); public Vehicle() { } @@ -145,30 +174,33 @@ public class Vehicle { this.capacity = capacity; this.homeLocation = homeLocation; this.departureTime = departureTime; - this.visits = new ArrayList<>(); } // Getters and Setters excluded public int getTotalDemand() { - if (visits.isEmpty()) { - return 0; + int totalDemand = 0; + for (Visit visit : visits) { + totalDemand += visit.getDemand(); } - Visit lastVisit = visits.get(visits.size() - 1); - Integer cumulativeDemand = lastVisit.getCumulativeDemand(); - return cumulativeDemand == null ? 0 : cumulativeDemand; + return totalDemand; } public long getTotalDrivingTimeSeconds() { if (visits.isEmpty()) { return 0; } - Visit lastVisit = visits.get(visits.size() - 1); - Long cumulativeDrivingTime = lastVisit.getCumulativeDrivingTimeSeconds(); - if (cumulativeDrivingTime == null) { - return 0; + + long totalDrivingTime = 0; + Location previousLocation = homeLocation; + + for (Visit visit : visits) { + totalDrivingTime += previousLocation.getDrivingTimeTo(visit.getLocation()); + previousLocation = visit.getLocation(); } - return cumulativeDrivingTime + lastVisit.getLocation().getDrivingTimeTo(homeLocation); + totalDrivingTime += previousLocation.getDrivingTimeTo(homeLocation); + + return totalDrivingTime; } @Override @@ -189,12 +221,16 @@ Create the `src/main/kotlin/org/acme/vehiclerouting/domain/Vehicle.kt` class: package org.acme.vehiclerouting.domain import java.time.LocalDateTime -import java.util.ArrayList import ai.timefold.solver.core.api.domain.entity.PlanningEntity import ai.timefold.solver.core.api.domain.common.PlanningId import ai.timefold.solver.core.api.domain.variable.PlanningListVariable +import com.fasterxml.jackson.annotation.JsonIdentityInfo +import com.fasterxml.jackson.annotation.JsonIdentityReference +import com.fasterxml.jackson.annotation.ObjectIdGenerators + +@JsonIdentityInfo(scope = Vehicle::class, generator = ObjectIdGenerators.PropertyGenerator::class, property = "id") @PlanningEntity class Vehicle { @PlanningId @@ -203,8 +239,9 @@ class Vehicle { lateinit var homeLocation: Location lateinit var departureTime: LocalDateTime + @JsonIdentityReference(alwaysAsId = true) @PlanningListVariable - var visits: List? = null + var visits: MutableList = ArrayList() constructor() @@ -213,13 +250,12 @@ class Vehicle { this.capacity = capacity this.homeLocation = homeLocation this.departureTime = departureTime - this.visits = ArrayList() } val totalDemand: Long get() { var totalDemand = 0L - for (visit in visits!!) { + for (visit in visits) { totalDemand += visit.demand } return totalDemand @@ -227,16 +263,16 @@ class Vehicle { val totalDrivingTimeSeconds: Long get() { - if (visits!!.isEmpty()) { + if (visits.isEmpty()) { return 0 } var totalDrivingTime: Long = 0 var previousLocation = homeLocation - for (visit in visits!!) { - totalDrivingTime += previousLocation.getDrivingTimeTo(visit.location!!) - previousLocation = visit.location!! + for (visit in visits) { + totalDrivingTime += previousLocation.getDrivingTimeTo(visit.location) + previousLocation = visit.location } totalDrivingTime += previousLocation.getDrivingTimeTo(homeLocation) @@ -289,13 +325,15 @@ import java.time.LocalDateTime; import ai.timefold.solver.core.api.domain.entity.PlanningEntity; import ai.timefold.solver.core.api.domain.common.PlanningId; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; -import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable; import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; -import ai.timefold.solver.core.api.domain.variable.ShadowVariable; -import org.acme.vehiclerouting.solver.ArrivalTimeUpdatingVariableListener; +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.JsonIdentityReference; +import com.fasterxml.jackson.annotation.ObjectIdGenerators; +@JsonIdentityInfo(scope = Visit.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") @PlanningEntity public class Visit { @@ -308,9 +346,11 @@ public class Visit { private LocalDateTime maxEndTime; private Duration serviceDuration; + @JsonIdentityReference(alwaysAsId = true) @InverseRelationShadowVariable(sourceVariableName = "visits") private Vehicle vehicle; + @JsonIdentityReference(alwaysAsId = true) @PreviousElementShadowVariable(sourceVariableName = "visits") private Visit previousVisit; @@ -333,7 +373,7 @@ public class Visit { // Getters and Setters excluded - private void updateArrivalTime() { + public void updateArrivalTime() { if (previousVisit == null && vehicle == null) { arrivalTime = null; return; @@ -401,13 +441,15 @@ import java.time.LocalDateTime import ai.timefold.solver.core.api.domain.entity.PlanningEntity import ai.timefold.solver.core.api.domain.common.PlanningId +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable -import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable -import ai.timefold.solver.core.api.domain.variable.ShadowVariable -import org.acme.vehiclerouting.solver.ArrivalTimeUpdatingVariableListener +import com.fasterxml.jackson.annotation.JsonIdentityInfo +import com.fasterxml.jackson.annotation.JsonIdentityReference +import com.fasterxml.jackson.annotation.ObjectIdGenerators +@JsonIdentityInfo(scope = Visit::class, generator = ObjectIdGenerators.PropertyGenerator::class, property = "id") @PlanningEntity class Visit { @PlanningId @@ -419,9 +461,11 @@ class Visit { lateinit var maxEndTime: LocalDateTime lateinit var serviceDuration: Duration + @JsonIdentityReference(alwaysAsId = true) @InverseRelationShadowVariable(sourceVariableName = "visits") - private var vehicle: Vehicle? = null + var vehicle: Vehicle? = null + @JsonIdentityReference(alwaysAsId = true) @PreviousElementShadowVariable(sourceVariableName = "visits") var previousVisit: Visit? = null @@ -443,13 +487,13 @@ class Visit { this.serviceDuration = serviceDuration } - private fun updateArrivalTime() { + fun updateArrivalTime() { if (previousVisit == null && vehicle == null) { arrivalTime = null return } val departureTime = previousVisit?.departureTime ?: vehicle?.departureTime - arrivalTime = departureTime?.plusSeconds(getDrivingTimeSecondsFromPreviousStandstill()) + arrivalTime = departureTime?.plusSeconds(drivingTimeSecondsFromPreviousStandstill) } val departureTime: LocalDateTime? @@ -524,3 +568,7 @@ NOTE: `@NextElementShadowVariable` also exists, which can be used to get a refer The `arrivalTime` field has a `@CascadingUpdateShadowVariable` annotation. This annotation indicates which method should be triggered to update this field whenever this entity is moved, in this case the `updateArrivalTime()` method. This change is automatically propagated to the subsequent visits and stops when the `arrivalTime` value hasn't changed or when it's reached the end of the chain of visit objects. + +The Jackson `@JsonIdentityInfo` and `@JsonIdentityReference` annotations on `Vehicle` and `Visit` +serialize the `visits`, `vehicle`, and `previousVisit` references as IDs. +Without them, a visit and its vehicle reference each other, so the JSON response never ends. diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc index a9e80b126f4..61c263969f2 100644 --- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc @@ -31,7 +31,6 @@ import ai.timefold.solver.core.api.domain.solution.PlanningScore; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; import ai.timefold.solver.core.api.score.HardSoftScore; -import ai.timefold.solver.core.api.solver.SolverStatus; import org.acme.vehiclerouting.domain.geo.DrivingTimeCalculator; import org.acme.vehiclerouting.domain.geo.HaversineDrivingTimeCalculator; @@ -60,8 +59,12 @@ public class VehicleRoutePlan { this.name = name; this.vehicles = vehicles; this.visits = visits; + initDrivingTimeMaps(); + } - // Enhance locations with a pre-calculated driving time map + // Enhance locations with a pre-calculated driving time map. + // Jackson uses the no-arg constructor, so a deserialized plan must call this method before solving. + public void initDrivingTimeMaps() { List locations = Stream.concat( vehicles.stream().map(Vehicle::getHomeLocation), visits.stream().map(Visit::getLocation)).toList(); @@ -84,7 +87,6 @@ Create the `src/main/kotlin/org/acme/vehiclerouting/domain/VehicleRoutePlan.kt` ---- package org.acme.vehiclerouting.domain; -import java.time.LocalDateTime import java.util.stream.Stream import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty @@ -92,7 +94,6 @@ import ai.timefold.solver.core.api.domain.solution.PlanningScore import ai.timefold.solver.core.api.domain.solution.PlanningSolution import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider import ai.timefold.solver.core.api.score.HardSoftScore -import ai.timefold.solver.core.api.solver.SolverStatus import org.acme.vehiclerouting.domain.geo.DrivingTimeCalculator import org.acme.vehiclerouting.domain.geo.HaversineDrivingTimeCalculator @@ -125,11 +126,15 @@ class VehicleRoutePlan { this.name = name this.vehicles = vehicles this.visits = visits + initDrivingTimeMaps() + } - // Enhance locations with a pre-calculated driving time map + // Enhance locations with a pre-calculated driving time map. + // Jackson uses the no-arg constructor, so a deserialized plan must call this method before solving. + fun initDrivingTimeMaps() { val locations = Stream.concat( - vehicles.stream().map({ obj: Vehicle -> obj.homeLocation }), - visits.stream().map({ obj: Visit -> obj.location }) + vehicles!!.stream().map({ obj: Vehicle -> obj.homeLocation }), + visits!!.stream().map({ obj: Visit -> obj.location }) ).toList() val drivingTimeCalculator: DrivingTimeCalculator = HaversineDrivingTimeCalculator.INSTANCE diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc index 051d8f32720..ba4a6776521 100644 --- a/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc @@ -11,6 +11,7 @@ include::../../_attributes.adoc[] :testannotation: @QuarkusTest :testannotationfq: io.quarkus.test.junit.QuarkusTest :framework-quickstart-url: {quarkus-quickstart-url} +:framework-kotlin-quickstart-url: {quarkus-kotlin-quickstart-url} // Keep this in sync with the quarkus repo's copy // https://github.com/quarkusio/quarkus/blob/main/docs/src/main/asciidoc/timefold.adoc @@ -81,13 +82,13 @@ import ai.timefold.solver.core.api.solver.SolverManager; public class TimetableResource { @Inject - SolverManager solverManager; + SolverManager solverManager; @POST public Timetable solve(Timetable problem) { UUID problemId = UUID.randomUUID(); // Submit the problem to start solving - SolverJob solverJob = solverManager.solve(problemId, problem); + SolverJob solverJob = solverManager.solve(problemId, problem); Timetable solution; try { // Wait until the solving ends @@ -110,41 +111,25 @@ Create the `src/main/kotlin/org/acme/schooltimetabling/rest/TimetableResource.kt ---- package org.acme.schooltimetabling.rest -import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis -import ai.timefold.solver.core.api.score.HardSoftScore -import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy -import ai.timefold.solver.core.api.solver.SolutionManager import ai.timefold.solver.core.api.solver.SolverManager import jakarta.inject.Inject -import jakarta.ws.rs.Consumes -import jakarta.ws.rs.DELETE -import jakarta.ws.rs.GET import jakarta.ws.rs.POST -import jakarta.ws.rs.PUT import jakarta.ws.rs.Path -import jakarta.ws.rs.PathParam -import jakarta.ws.rs.Produces -import jakarta.ws.rs.QueryParam -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response import org.acme.schooltimetabling.domain.Timetable -import org.acme.schooltimetabling.rest.exception.ErrorInfo -import org.acme.schooltimetabling.rest.exception.TimetableSolverException -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.util.* +import java.util.UUID +import java.util.concurrent.ExecutionException -@Path("timetables") +@Path("/timetables") class TimetableResource { @Inject - private final var solverManager: SolverManager? + lateinit var solverManager: SolverManager @POST - fun solve(problem: Timetable?): Timetable { + fun solve(problem: Timetable): Timetable { val problemId = UUID.randomUUID() // Submit the problem to start solving - val solverJob = solverManager!!.solve(problemId, problem) + val solverJob = solverManager.solve(problemId, problem) val solution: Timetable try { // Wait until the solving ends @@ -260,40 +245,30 @@ In a JUnit test, generate a test dataset and send it to the `TimetableResource` Java:: + -- +Save the JSON request body of the earlier `curl` command as the `src/test/resources/testing-timetable.json` file. + Create the `src/test/java/org/acme/schooltimetabling/rest/TimetableResourceTest.java` class: [source,java] ---- package org.acme.schooltimetabling.rest; -import static io.restassured.RestAssured.get; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import jakarta.inject.Singleton; - -import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; -import ai.timefold.solver.core.api.score.HardSoftScore; -import ai.timefold.solver.core.api.score.constraint.ConstraintRef; -import ai.timefold.solver.core.api.score.stream.ConstraintJustification; -import ai.timefold.solver.core.api.solver.SolverStatus; -import ai.timefold.solver.jackson.api.score.analysis.AbstractScoreAnalysisJacksonDeserializer; +import java.io.File; +import java.io.IOException; import org.acme.schooltimetabling.domain.Timetable; import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.json.JsonMapper; -import io.quarkus.jackson.ObjectMapperCustomizer; import io.quarkus.test.junit.QuarkusTest; import io.restassured.http.ContentType; -import java.io.IOException; -import java.io.File; - @QuarkusTest class TimetableResourceTest { @@ -303,10 +278,9 @@ class TimetableResourceTest { ObjectMapper objectMapper = JsonMapper.builder() .findAndAddModules() .build(); - Timetable testTimetable = objectMapper -.readValue( - new File("src/test/resources/testing-timetable.json"), - Timetable.class); + Timetable testTimetable = objectMapper.readValue( + new File("src/test/resources/testing-timetable.json"), + Timetable.class); Timetable solution = given() .contentType(ContentType.JSON) @@ -338,43 +312,52 @@ Create the `src/test/kotlin/org/acme/schooltimetabling/rest/TimetableResourceTes ---- package org.acme.schooltimetabling.rest -import ai.timefold.solver.core.api.solver.SolverStatus import io.quarkus.test.junit.QuarkusTest -import io.restassured.RestAssured.get import io.restassured.RestAssured.given import io.restassured.http.ContentType +import org.acme.schooltimetabling.domain.Lesson import org.acme.schooltimetabling.domain.Room import org.acme.schooltimetabling.domain.Timeslot import org.acme.schooltimetabling.domain.Timetable -import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import java.time.Duration +import java.time.DayOfWeek +import java.time.LocalTime @QuarkusTest class TimetableResourceTest { @Test fun solveDemoDataUntilFeasible() { - val testTimetable: Timetable = // setup test data + // Provide the same input data shown in the text above. + val testTimetable = Timetable( + listOf( + Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)), + Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30))), + listOf(Room("Room A"), Room("Room B")), + listOf( + Lesson("1", "Math", "A. Turing", "9th grade"), + Lesson("2", "Chemistry", "M. Curie", "9th grade"), + Lesson("3", "French", "M. Curie", "10th grade"), + Lesson("4", "History", "I. Jones", "10th grade"))) val solution: Timetable = given() .contentType(ContentType.JSON) .body(testTimetable) - .expect().contentType(ContentType.TEXT) + .expect().contentType(ContentType.JSON) .`when`().post("/timetables") .then() .statusCode(200) .extract() - .as(Timetable.class); + .`as`(Timetable::class.java) assertNotNull(solution.lessons) assertNotNull(solution.timeslots) assertNotNull(solution.rooms) - assertNotNull(solution.lessons.get(0).room) - assertNotNull(solution.lessons.get(0).timeslot) - assertTrue(solution.score?.isFeasible!!) + assertNotNull(solution.lessons[0].room) + assertNotNull(solution.lessons[0].timeslot) + assertTrue(solution.score!!.isFeasible) } } @@ -441,5 +424,5 @@ You have just developed a Quarkus application with https://timefold.ai[Timefold] Next Steps: -- For a full implementation with a web UI and in-memory storage, check out {quarkus-quickstart-url}[the Quarkus quickstart source code]. +- For a full implementation with a web UI and in-memory storage, check out {quarkus-quickstart-url}[the Quarkus quickstart source code] (Java) or {quarkus-kotlin-quickstart-url}[the Kotlin version]. - Check out more information about Timefold's integration with Quarkus in our xref:running-timefold-solver/library/quarkus.adoc#integrationWithQuarkus[Integration documentation]. diff --git a/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc b/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc index de6d59fb982..72cef14d308 100644 --- a/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc @@ -67,6 +67,7 @@ Your `pom.xml` file has the following content: 1.0.0-SNAPSHOT + {java-version} @@ -175,7 +176,7 @@ import ai.timefold.solver.core.api.score.HardMediumSoftScore import ai.timefold.solver.service.definition.api.AbstractSimpleModel @PlanningSolution -class Timetable : AbstractSimpleModel() { +class Timetable() : AbstractSimpleModel() { @ValueRangeProvider @ProblemFactCollectionProperty @@ -188,9 +189,7 @@ class Timetable : AbstractSimpleModel() { @PlanningEntityCollectionProperty var lessons: List = emptyList() - constructor() - - constructor(timeslots: List, rooms: List, lessons: List) { + constructor(timeslots: List, rooms: List, lessons: List) : this() { this.timeslots = timeslots this.rooms = rooms this.lessons = lessons @@ -251,6 +250,7 @@ timefold.model.termination.spent-limit=PT30S # Application metadata — included in the generated OpenAPI specification timefold.model.name=my-model +timefold.model.api-version=v1 timefold.model.contact.email=info@example.com timefold.model.contact.name=Your Name timefold.model.contact.url=https://example.com @@ -262,7 +262,9 @@ For production use, consider at least five minutes (`PT5M`) to allow the solver NOTE: The per-request `config.run.termination.spentLimit` field in the POST body (shown later) overrides this default for individual runs. -`timefold.model.name` and the contact fields are *required* metadata. +`timefold.model.name`, `timefold.model.api-version` and the contact fields are *required* metadata. +Use a version such as `v1` for `timefold.model.api-version`. +It can only contain letters, numbers, hyphens (`-`) and underscores (`_`), so a value such as `1.0.0` fails the build. They are used to identify your service, validate the JSON schema of requests, and populate the generated OpenAPI specification. == REST API @@ -298,8 +300,8 @@ public interface TimetableResource extends ModelRest { This is the most basic version of the interface. The SDK will automatically create multiple xref:running-timefold-solver/service/rest-api.adoc[REST API endpoints] for optimization actions. The `@Path` annotation is used to configure the base path of all those endpoints. -NOTE: Do not include an API version in `@Path`. The SDK default configuration sets `quarkus.rest.path=${timefold.model.version}`, which Quarkus automatically prepends to every `@Path` at runtime. -So with `timefold.model.version=v1`, `@Path("/timetables")` is served at `/v1/timetables` — locally and on the platform. +NOTE: Do not include an API version in `@Path`. The SDK default configuration sets `quarkus.rest.path=${timefold.model.api-version}`, which Quarkus automatically prepends to every `@Path` at runtime. +So with `timefold.model.api-version=v1`, `@Path("/timetables")` is served at `/v1/timetables` — locally and on the platform. == Running and manually testing the model diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc index 5437c95a7f4..0cc7b2d2ab3 100644 --- a/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc @@ -70,19 +70,19 @@ import java.time.LocalTime {testannotation} class TimetableConstraintProviderTest { - val ROOM1: Room = Room(1, "Room1") - private val TIMESLOT1: Timeslot = Timeslot(1, DayOfWeek.MONDAY, LocalTime.NOON) - private val TIMESLOT2: Timeslot = Timeslot(2, DayOfWeek.TUESDAY, LocalTime.NOON) + private val ROOM1: Room = Room("Room1") + private val TIMESLOT1: Timeslot = Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.NOON) + private val TIMESLOT2: Timeslot = Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.NOON) {wiringannotation} - lateinit var constraintVerifier: ConstraintVerifier + lateinit var constraintVerifier: ConstraintVerifier @Test fun roomConflict() { - val firstLesson = Lesson("1", "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1) - val conflictingLesson = Lesson("2", "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1) - val nonConflictingLesson = Lesson("3", "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1) - constraintVerifier.verifyThat(TimeTableConstraintProvider::roomConflict) + val firstLesson = Lesson("1", "Subject1", "Teacher1", "Group1").apply { timeslot = TIMESLOT1; room = ROOM1 } + val conflictingLesson = Lesson("2", "Subject2", "Teacher2", "Group2").apply { timeslot = TIMESLOT1; room = ROOM1 } + val nonConflictingLesson = Lesson("3", "Subject3", "Teacher3", "Group3").apply { timeslot = TIMESLOT2; room = ROOM1 } + constraintVerifier.verifyThat(TimetableConstraintProvider::roomConflict) .given(firstLesson, conflictingLesson, nonConflictingLesson) .penalizesBy(1) } diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc index 8073a7ae4d0..fe79a83c20d 100644 --- a/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc @@ -1,4 +1,4 @@ -Create the `src/main/resource/logback.xml` file: +Create the `src/main/resources/logback.xml` file: [source,xml] ---- diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc index 80aff075565..7c268fccde0 100644 --- a/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc @@ -2,4 +2,9 @@ Follow the instructions in the next sections to create the application step by step (recommended). -In case you get stuck during this Getting Started guide, it might be worth checking the solution project {framework-quickstart-url}[on GitHub]. \ No newline at end of file +ifdef::framework-kotlin-quickstart-url[] +In case you get stuck during this Getting Started guide, it might be worth checking the solution project on GitHub, for {framework-quickstart-url}[Java] or {framework-kotlin-quickstart-url}[Kotlin]. +endif::[] +ifndef::framework-kotlin-quickstart-url[] +In case you get stuck during this Getting Started guide, it might be worth checking the solution project {framework-quickstart-url}[on GitHub]. +endif::[] diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc index a2015805935..d18eebf30e4 100644 --- a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc @@ -99,18 +99,16 @@ Create a `src/main/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintP [source,kotlin,subs=attributes+] ---- -package org.acme.kotlin.schooltimetabling.solver +package org.acme.schooltimetabling.solver import ai.timefold.solver.core.api.score.{score-class} import ai.timefold.solver.core.api.score.stream.Constraint import ai.timefold.solver.core.api.score.stream.ConstraintFactory import ai.timefold.solver.core.api.score.stream.ConstraintProvider import ai.timefold.solver.core.api.score.stream.Joiners -import org.acme.kotlin.schooltimetabling.domain.Lesson -import org.acme.kotlin.schooltimetabling.solver.justifications.* -import java.time.Duration +import org.acme.schooltimetabling.domain.Lesson -class TimeTableConstraintProvider : ConstraintProvider { +class TimetableConstraintProvider : ConstraintProvider { override fun defineConstraints(constraintFactory: ConstraintFactory): Array { return arrayOf( @@ -118,10 +116,7 @@ class TimeTableConstraintProvider : ConstraintProvider { roomConflict(constraintFactory), teacherConflict(constraintFactory), studentGroupConflict(constraintFactory), - // Soft constraints - teacherRoomStability(constraintFactory), - teacherTimeEfficiency(constraintFactory), - studentGroupSubjectVariety(constraintFactory) + // Soft constraints are only implemented in the timefold-quickstarts code ) } @@ -138,8 +133,6 @@ class TimeTableConstraintProvider : ConstraintProvider { ) // ... and penalize each pair with a hard weight. .penalize({score-class}.ONE_HARD) - .justifyWith { lesson1: Lesson, lesson2: Lesson, _ -> - RoomConflictJustification(lesson1.room, lesson1,lesson2)} .asConstraint("Room conflict") } @@ -152,8 +145,6 @@ class TimeTableConstraintProvider : ConstraintProvider { Joiners.equal(Lesson::teacher) ) .penalize({score-class}.ONE_HARD) - .justifyWith { lesson1: Lesson, lesson2: Lesson, _ -> - TeacherConflictJustification(lesson1.teacher, lesson1, lesson2)} .asConstraint("Teacher conflict") } @@ -166,66 +157,9 @@ class TimeTableConstraintProvider : ConstraintProvider { Joiners.equal(Lesson::studentGroup) ) .penalize({score-class}.ONE_HARD) - .justifyWith { lesson1: Lesson, lesson2: Lesson, _ -> - StudentGroupConflictJustification(lesson1.studentGroup, lesson1, lesson2)} .asConstraint("Student group conflict") } - fun teacherRoomStability(constraintFactory: ConstraintFactory): Constraint { - // A teacher prefers to teach in a single room. - return constraintFactory - .forEachUniquePair( - Lesson::class.java, - Joiners.equal(Lesson::teacher) - ) - .filter { lesson1: Lesson, lesson2: Lesson -> lesson1.room !== lesson2.room } - .penalize({score-class}.ONE_SOFT) - .justifyWith { lesson1: Lesson, lesson2: Lesson, _ -> - TeacherRoomStabilityJustification(lesson1.teacher, lesson1, lesson2)} - .asConstraint("Teacher room stability") - } - - fun teacherTimeEfficiency(constraintFactory: ConstraintFactory): Constraint { - // A teacher prefers to teach sequential lessons and dislikes gaps between lessons. - return constraintFactory - .forEach(Lesson::class.java) - .join(Lesson::class.java, - Joiners.equal(Lesson::teacher), - Joiners.equal { lesson: Lesson -> lesson.timeslot?.dayOfWeek }) - .filter { lesson1: Lesson, lesson2: Lesson -> - val between = Duration.between( - lesson1.timeslot?.endTime, - lesson2.timeslot?.startTime - ) - !between.isNegative && between <= Duration.ofMinutes(30) - } - .reward({score-class}.ONE_SOFT) - .justifyWith{ lesson1: Lesson, lesson2: Lesson, _ -> - TeacherTimeEfficiencyJustification(lesson1.teacher, lesson1, lesson2)} - .asConstraint("Teacher time efficiency") - } - - fun studentGroupSubjectVariety(constraintFactory: ConstraintFactory): Constraint { - // A student group dislikes sequential lessons on the same subject. - return constraintFactory - .forEach(Lesson::class.java) - .join(Lesson::class.java, - Joiners.equal(Lesson::subject), - Joiners.equal(Lesson::studentGroup), - Joiners.equal { lesson: Lesson -> lesson.timeslot?.dayOfWeek }) - .filter { lesson1: Lesson, lesson2: Lesson -> - val between = Duration.between( - lesson1.timeslot?.endTime, - lesson2.timeslot?.startTime - ) - !between.isNegative && between <= Duration.ofMinutes(30) - } - .penalize({score-class}.ONE_SOFT) - .justifyWith { lesson1: Lesson, lesson2: Lesson, _ -> - StudentGroupSubjectVarietyJustification(lesson1.studentGroup, lesson1, lesson2)} - .asConstraint("Student group subject variety") - } - } ---- -- diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc index db2d27e224d..4f8bda80a8e 100644 --- a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc @@ -205,7 +205,7 @@ public class Lesson { } // This constructor is only for tests - Lesson(String id, String subject, String teacher, String studentGroup, Timeslot timeslot, Room room) { + public Lesson(String id, String subject, String teacher, String studentGroup, Timeslot timeslot, Room room) { this(id, subject, teacher, studentGroup); this.timeslot = timeslot; this.room = room; diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc index 30f1660589d..17d01a77691 100644 --- a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc @@ -71,6 +71,10 @@ public class Timetable { return score; } + public void setScore(HardSoftScore score) { + this.score = score; + } + } ---- -- @@ -78,7 +82,7 @@ public class Timetable { Kotlin:: + -- -Create the `src/main/kotlin/org/acme/schooltimetabling/TimetableApp.kt` class: +Create the `src/main/kotlin/org/acme/schooltimetabling/domain/Timetable.kt` class: [source,kotlin] ---- @@ -90,7 +94,6 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider import ai.timefold.solver.core.api.score.HardSoftScore -import ai.timefold.solver.core.api.solver.SolverStatus @PlanningSolution data class Timetable ( diff --git a/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc index b3a2a45782d..662cc4df8ce 100644 --- a/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc @@ -9,7 +9,7 @@ include::../../_attributes.adoc[] :framework: spring-boot :wiringannotation: @Autowired :wiringannotationfq: org.springframework.beans.factory.annotation.Autowired -:testannotation: @SpringTest +:testannotation: @SpringBootTest :testannotationfq: org.springframework.boot.test.context.SpringBootTest :framework-quickstart-url: {spring-boot-quickstart-url} @@ -33,6 +33,37 @@ Use https://start.spring.io/[start.spring.io] to generate an application with th * Spring Web (`spring-boot-starter-web`) * Timefold Solver (`timefold-solver-spring-boot-starter`) +The tests in this guide also use https://rest-assured.io/[REST Assured], +which Spring Boot does not manage. +Add it to your build file: + +[tabs] +==== +Maven:: ++ +-- +Add the subsequent dependency in your `pom.xml`: +[source,xml] +---- + + io.rest-assured + rest-assured + 6.3.0 + test + +---- +-- +Gradle:: ++ +-- +Add the subsequent dependency to your `build.gradle`: +[source,groovy] +---- + testImplementation "io.rest-assured:rest-assured:6.3.0" +---- +-- +==== + include::../shared/school-timetabling/_school-timetabling-model.adoc[leveloffset=+1] include::../shared/school-timetabling/_school-timetabling-constraints.adoc[leveloffset=+1] include::../shared/school-timetabling/_school-timetabling-solution.adoc[leveloffset=+1] @@ -73,13 +104,13 @@ import org.springframework.web.bind.annotation.RestController; public class TimetableController { @Autowired - private SolverManager solverManager; + private SolverManager solverManager; @PostMapping public Timetable solve(@RequestBody Timetable problem) { UUID problemId = UUID.randomUUID(); // Submit the problem to start solving - SolverJob solverJob = solverManager.solve(problemId, problem); + SolverJob solverJob = solverManager.solve(problemId, problem); Timetable solution; try { // Wait until the solving ends @@ -115,7 +146,7 @@ import java.util.concurrent.ExecutionException @RequestMapping("/timetable") class TimetableController { @Autowired - private val solverManager: SolverManager? = null + private val solverManager: SolverManager? = null @PostMapping fun solve(@RequestBody problem: Timetable): Timetable { @@ -200,12 +231,10 @@ import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication @SpringBootApplication -open class TimetableSpringBootApp { - - fun main(args: Array) { - SpringApplication.run(TimeTableSpringBootApp::class.java, *args) - } +open class TimetableSpringBootApp +fun main(args: Array) { + SpringApplication.run(TimetableSpringBootApp::class.java, *args) } ---- -- @@ -308,7 +337,7 @@ Kotlin:: ---- package org.acme.schooltimetabling.config -import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier; +import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier import org.acme.schooltimetabling.domain.Lesson import org.acme.schooltimetabling.domain.Timetable import org.acme.schooltimetabling.solver.TimetableConstraintProvider @@ -347,15 +376,18 @@ Create the `src/test/java/org/acme/schooltimetabling/rest/TimetableControllerTes ---- package org.acme.schooltimetabling.rest; -import ai.timefold.solver.core.api.solver.SolverStatus; import io.restassured.http.ContentType; +import org.acme.schooltimetabling.domain.Lesson; +import org.acme.schooltimetabling.domain.Room; +import org.acme.schooltimetabling.domain.Timeslot; import org.acme.schooltimetabling.domain.Timetable; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import java.time.Duration; +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.List; -import static io.restassured.RestAssured.get; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.*; @@ -368,15 +400,25 @@ class TimetableControllerTest { @Test void solveDemoDataUntilFeasible() { - Timetable testTimetable = // setup test data + // Provide the same input data shown in the text above. + Timetable testTimetable = new Timetable( + List.of( + new Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)), + new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30))), + List.of(new Room("Room A"), new Room("Room B")), + List.of( + new Lesson("1", "Math", "A. Turing", "9th grade"), + new Lesson("2", "Chemistry", "M. Curie", "9th grade"), + new Lesson("3", "French", "M. Curie", "10th grade"), + new Lesson("4", "History", "I. Jones", "10th grade"))); Timetable solution = given() .contentType(ContentType.JSON) .body(testTimetable) - .expect().contentType(ContentType.TEXT) - .when().post("/timetables") + .expect().contentType(ContentType.JSON) + .when().post("/timetable") .then() - .statusCode(201) + .statusCode(200) .extract() .as(Timetable.class); @@ -400,15 +442,17 @@ Create the `src/test/kotlin/org/acme/schooltimetabling/rest/TimetableControllerT ---- package org.acme.schooltimetabling.rest -import ai.timefold.solver.core.api.solver.SolverStatus import io.restassured.RestAssured import io.restassured.http.ContentType +import org.acme.schooltimetabling.domain.Lesson +import org.acme.schooltimetabling.domain.Room +import org.acme.schooltimetabling.domain.Timeslot import org.acme.schooltimetabling.domain.Timetable -import org.awaitility.Awaitility import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest -import java.time.Duration +import java.time.DayOfWeek +import java.time.LocalTime @SpringBootTest( properties = [ // Effectively disable spent-time termination in favor of the best-score-limit @@ -419,24 +463,34 @@ import java.time.Duration internal class TimetableControllerTest { @Test fun solveDemoDataUntilFeasible() { - val testTimetable = //setup test data + // Provide the same input data shown in the text above. + val testTimetable = Timetable( + listOf( + Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)), + Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30))), + listOf(Room("Room A"), Room("Room B")), + listOf( + Lesson("1", "Math", "A. Turing", "9th grade"), + Lesson("2", "Chemistry", "M. Curie", "9th grade"), + Lesson("3", "French", "M. Curie", "10th grade"), + Lesson("4", "History", "I. Jones", "10th grade"))) val solution = RestAssured.given() .contentType(ContentType.JSON) .body(testTimetable) - .expect().contentType(ContentType.TEXT) - .`when`().post("/timetables") + .expect().contentType(ContentType.JSON) + .`when`().post("/timetable") .then() - .statusCode(201) + .statusCode(200) .extract() - .as(Timetable.class) + .`as`(Timetable::class.java) Assertions.assertNotNull(solution.lessons) Assertions.assertNotNull(solution.timeslots) Assertions.assertNotNull(solution.rooms) Assertions.assertNotNull(solution.lessons[0].room) Assertions.assertNotNull(solution.lessons[0].timeslot) - Assertions.assertTrue(solution.score.isFeasible) + Assertions.assertTrue(solution.score!!.isFeasible) } } ----